このコミットが含まれているのは:
2026-10-26 22:57:39 +09:00
コミット 5493673996
60個のファイルの変更、1202行の追加、273行の削除
+13
ファイルの表示
@@ -1,5 +1,18 @@
# AGENTS.md
## Mandatory security and URL contracts
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
## Project overview
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
+15
ファイルの表示
@@ -1,5 +1,20 @@
# backend/AGENTS.md
## Mandatory security and URL contracts
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。
## Scope
These rules apply to work under `backend/`.
+22 -22
ファイルの表示
@@ -1,4 +1,5 @@
class ApplicationController < ActionController::API
include RequestAccess
include ActionController::Cookies
include ActionController::RequestForgeryProtection
@@ -8,34 +9,46 @@ class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
rescue_from(ActiveRecord::RecordNotFound) { head :not_found }
rescue_from(UnsupportedLocale) { head :not_found }
before_action :reject_banned_ip_address!
before_action :authenticate_user
before_action :reject_banned_user!
before_action :validate_explicit_locale!
before_action :enforce_query_contract!
around_action :with_history_authority
rescue_from(HistoryAccess::Forbidden) { head :not_found }
protect_from_forgery with: :exception
self.forgery_protection_origin_check = true
rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf
after_action :prevent_authenticated_caching
def current_user = @current_user
private
def enforce_query_contract!
pairs = URI.decode_www_form(request.query_string)
return head :bad_request if pairs.length > 1000 || pairs.map(&:first).uniq.length != pairs.length
return head :bad_request if request.query_parameters.values.any? { !_1.is_a?(String) }
if (controller_name.start_with?('counts_') &&
(request.query_parameters.keys & ['headers', 'labels']).any?)
return head :bad_request
end
rescue ArgumentError
head :bad_request
end
def with_history_authority(&action)
response.headers['Cache-Control'] = 'private, no-store'
HistoryAccess.set(user: current_user, &action)
end
def reject_invalid_csrf
response.headers['X-CSRF-Invalid'] = '1'
response.headers['Cache-Control'] = 'private, no-store'
head :forbidden
end
def authenticate_user
code = session[:inheritance_code]
return if code.blank?
@current_user = User.find_by(inheritance_code: code)
end
def prevent_authenticated_caching
response.headers['Cache-Control'] = 'private, no-store' if current_user
end
@@ -79,19 +92,6 @@ class ApplicationController < ActionController::API
render_validation_error base: ['すでに存在してゐます.']
end
def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?
head :forbidden
end
def reject_banned_user!
return unless current_user&.banned?
head :forbidden
end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
errors = { }
+1 -1
ファイルの表示
@@ -6,7 +6,7 @@ module CountsListing
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,
result = { population_count_scope: 'all_recorded_versions', population_count: HistoryAccess.population_count(query.relation.klass), 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)
+52
ファイルの表示
@@ -0,0 +1,52 @@
# Permanent storage URLs must not bypass a resource's later discard boundary.
module HistoryStorageAccess
extend ActiveSupport::Concern
included do
include RequestAccess
prepend_before_action :authorise_history_storage!
end
private
def authorise_history_storage!
response.headers['Cache-Control'] = 'private, no-store'
return unless request.get? || request.head?
reject_banned_ip_address!
return if performed?
authenticate_user
reject_banned_user!
return if performed?
signed = params[:signed_blob_id] || params[:signed_id]
blob = if signed
ActiveStorage::Blob.find_signed(signed)
elsif params[:encoded_key]
payload = ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
key = payload && (payload[:key] || payload['key'])
if key
original = key.start_with?('variants/') ? key.split('/')[1] : key
ActiveStorage::Blob.find_by(key: original)
end
end
return head :not_found unless blob
allowed = HistoryAccess.set(user: current_user) { HistoryAccess.blob_visible?(blob) }
head :not_found unless allowed
rescue ActiveSupport::MessageVerifier::InvalidSignature
head :not_found
end
# Override the engine's public-forever caching before a streaming response commits.
def http_cache_forever(**_options)
response.headers['Cache-Control'] = 'private, no-store'
yield
end
def expires_in(*arguments, **options)
super
response.headers['Cache-Control'] = 'private, no-store'
end
end
+28
ファイルの表示
@@ -0,0 +1,28 @@
# Canonical read-side session and BAN boundary, shared by API and storage controllers.
module RequestAccess
extend ActiveSupport::Concern
def current_user = @current_user
private
def authenticate_user
code = session[:inheritance_code]
return if code.blank?
@current_user = User.find_by(inheritance_code: code)
end
def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?
head :forbidden
end
def reject_banned_user!
return unless current_user&.banned?
head :forbidden
end
end
+9 -9
ファイルの表示
@@ -68,15 +68,15 @@ class CountsExportsController < ApplicationController
.merge(request.query_parameters)
exporter = CountsExporter.new(payload,
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
session[:counts_export_owner] ||= SecureRandom.hex(32)
token = CountsExportDownload.prepare(exporter, session[:counts_export_owner])
file = CountsExportDownload.find(token, session[:counts_export_owner])
return head :not_found unless file
send_file file.fetch('path'),
filename: "counts-#{ exporter.resource }.#{ exporter.format }",
type: exporter.format == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
+2 -2
ファイルの表示
@@ -8,7 +8,7 @@ class MaterialVersionsController < ApplicationController
offset = (page - 1) * limit
q = MaterialVersion.includes(:created_by_user)
q = HistoryAccess.visible(MaterialVersion.includes(:created_by_user))
q = q.where(material_id: params[:material_id]) if params[:material_id].present?
q = q.where(tag_name: params[:tag].to_s.strip) if params[:tag].present?
q = q.where(event_type: params[:event_type]) if params[:event_type].present?
@@ -20,7 +20,7 @@ class MaterialVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions), count: }
render json: { versions: serialise_versions(versions), count:, population_count: HistoryAccess.population_count(MaterialVersion) }
end
private
+3 -3
ファイルの表示
@@ -14,7 +14,7 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit
q = version_scope
q = HistoryAccess.visible(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,' +
@@ -32,7 +32,7 @@ class PostVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions, locale.language_code), count: }
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) }
end
private
@@ -101,7 +101,7 @@ class PostVersionsController < ApplicationController
}
missing_ids = ids - names.keys
NicoTagVersion
HistoryAccess.visible(NicoTagVersion.all)
.where(tag_id: missing_ids)
.order(:tag_id, version_no: :desc)
.pluck(:tag_id, :name)
+3 -2
ファイルの表示
@@ -38,7 +38,7 @@ class PostsController < ApplicationController
offset = (page - 1) * limit
pt_max_sql =
PostVersion
HistoryAccess.visible(PostVersion.all)
.select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id')
.to_sql
@@ -344,6 +344,7 @@ class PostsController < ApplicationController
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)
HistoryAccess.require!(base_version)
base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post, locale)
end
@@ -449,7 +450,7 @@ class PostsController < ApplicationController
private
def tag_filter_references literals
PostTagSearch.parse_references(params[:tag_refs], literals)
PostTagSearch.parse_references(params.to_unsafe_h, literals)
rescue ArgumentError, JSON::ParserError, TypeError
raise ActionController::BadRequest
end
+2 -1
ファイルの表示
@@ -42,7 +42,7 @@ class SitemapEntriesController < ApplicationController
when 'descriptions'
TagDescription.where(language_code: locale.language_code, tag_id: named_tags)
.where.not(body: [nil, ''])
.where(TagDescriptionVersion
.where(HistoryAccess.visible(TagDescriptionVersion.all)
.where('tag_description_versions.tag_id = tag_descriptions.tag_id')
.where('tag_description_versions.language_code = tag_descriptions.language_code')
.where('tag_description_versions.version_no = tag_descriptions.version_no')
@@ -50,6 +50,7 @@ class SitemapEntriesController < ApplicationController
when 'tag_groups'
TagGroup.joins(:versions)
.where('tag_group_versions.version_no = tag_groups.version_no')
.where(Arel.sql(HistoryAccess.sql(TagGroupVersion)))
when 'theatres'
Theatre.kept
end
+1
ファイルの表示
@@ -49,6 +49,7 @@ class TagDescriptionsController < ApplicationController
private
def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion)
description.as_json(only: [
:tag_id,
:language_code,
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class TagGroupsController < ApplicationController
def index
locale = resolve_locale!
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a)
end
+3 -2
ファイルの表示
@@ -10,7 +10,7 @@ class TagVersionsController < ApplicationController
offset = (page - 1) * limit
q = version_scope
q = HistoryAccess.visible(version_scope)
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
count = q.except(:select, :order, :limit, :offset).count
@@ -19,7 +19,7 @@ class TagVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions, locale.language_code), count: }
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(TagVersion) }
end
private
@@ -31,6 +31,7 @@ class TagVersionsController < ApplicationController
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
AND #{ HistoryAccess.sql(TagVersion, 'prev') }
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
+14 -14
ファイルの表示
@@ -8,12 +8,12 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip
descriptions =
TagDescription
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
.where(language_code: locale.language_code)
.includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions =
@@ -41,13 +41,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language)
names = TagDescription.joins(tag: :tag_names)
names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
candidates.concat(HistoryAccess.current(TagGroup.all, TagGroupVersion).order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:)
end)
render json: candidates
@@ -286,12 +286,12 @@ class WikiPagesController < ApplicationController
tag_group = id && TagGroup.find_by(id:)
if tag_group
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
return render json: tag_group_changes(HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: tag_group.id))
end
json = []
unless id
json.concat(tag_group_changes(TagGroupVersion.all))
json.concat(tag_group_changes(HistoryAccess.visible(TagGroupVersion.all)))
json.concat(description_changes(locale,
TagDescription.where(language_code: locale.language_code)))
json = json.each_with_index.sort_by do |row, index|
@@ -327,7 +327,7 @@ class WikiPagesController < ApplicationController
def render_group_representation representation, version, body: nil, status: :ok
body ||= TagGroupMarkdownRenderer.render(representation)
versions = TagGroupVersion.where(tag_group_id: version.tag_group_id)
versions = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: version.tag_group_id)
pred = versions.find_by(version_no: version.version_no - 1)&.id
succ = versions.find_by(version_no: version.version_no + 1)&.id
updated_at = version.created_at
@@ -346,11 +346,11 @@ class WikiPagesController < ApplicationController
end
def current_group_version group
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
end
def group_version group, version_id
TagGroupVersion.find_by(tag_group_id: group.id, id: version_id)
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, id: version_id)
end
def wiki_diff from_body, to_body
@@ -373,7 +373,7 @@ class WikiPagesController < ApplicationController
def tag_group_changes versions
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id)
predecessors = TagGroupVersion.where(tag_group_id: groups.keys,
predecessors = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: groups.keys,
version_no: rows.map { _1.version_no - 1 })
.pluck(:tag_group_id, :version_no, :id)
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
@@ -406,18 +406,18 @@ class WikiPagesController < ApplicationController
end
def description_versions description
TagDescriptionVersion.where(tag_id: description.tag_id,
HistoryAccess.visible(TagDescriptionVersion.all).where(tag_id: description.tag_id,
language_code: description.language_code)
end
def tag_description_by_title locale, title
TagDescription.joins(tag: :tag_names)
HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
.find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title })
end
def description_changes locale, descriptions
versions = TagDescriptionVersion.where(language_code: locale.language_code,
versions = HistoryAccess.visible(TagDescriptionVersion.all).where(language_code: locale.language_code,
tag_id: descriptions.select(:tag_id))
.order(id: :desc).limit(200).to_a
return [] if versions.empty?
@@ -425,7 +425,7 @@ class WikiPagesController < ApplicationController
by_tag = descriptions.where(tag_id: versions.map(&:tag_id))
.includes(tag: :tag_names).index_by(&:tag_id)
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
predecessors = TagDescriptionVersion.where(
predecessors = HistoryAccess.visible(TagDescriptionVersion.all).where(
language_code: locale.language_code, tag_id: by_tag.keys,
version_no: versions.map { |version| version.version_no - 1 })
.pluck(:tag_id, :version_no, :id)
+151
ファイルの表示
@@ -0,0 +1,151 @@
require 'digest'
# Request/worker authority comes only from a server-resolved user. Default is public.
class HistoryAccess < ActiveSupport::CurrentAttributes
attribute :user
class Forbidden < StandardError; end
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
'nico_tag_versions' => ['tag_id'],
'tag_description_versions' => ['tag_id', 'language_code'],
'tag_group_versions' => ['tag_group_id'], 'material_versions' => ['material_id'] }.freeze
def self.revision
values = KEYS.keys.map do |table|
table.classify.constantize.unscoped.where(event_type: 'discard').maximum(:id)
end
Digest::SHA256.hexdigest(values.to_json)
end
def self.authority
{ 'policy_revision' => revision, 'admin' => admin?, 'actor_id' => user&.id }
end
def self.authorised_artifact?(metadata)
return false unless metadata['policy_revision'].is_a?(String)
if metadata['admin']
admin? && user.id == metadata['actor_id']
else
metadata['policy_revision'] == revision
end
end
def self.admin? = user&.admin? == true
def self.sql(model, row = model.table_name, references: true)
return 'TRUE' if admin?
table = model.table_name
keys = KEYS.fetch(table)
matches = keys.map { "boundary.#{ _1 } = #{ row }.#{ _1 }" }.join(' AND ')
clauses = ["NOT EXISTS (SELECT 1 FROM #{ table } boundary WHERE #{ matches } " +
"AND boundary.event_type = 'discard' AND boundary.version_no >= #{ row }.version_no)"]
clauses += reference_sql(table, row) if references
clauses.map { "(#{ _1 })" }.join(' AND ')
end
def self.discard_reference(table, key, expression)
"NOT EXISTS (SELECT 1 FROM #{ table } blocked WHERE blocked.event_type = 'discard' " +
"AND #{ expression.call("blocked.#{ key }") })"
end
def self.reference_sql(table, row)
case table
when 'post_versions'
[['tag_versions', 'tag_id', 'tag_id'],
['nico_tag_versions', 'tag_id', 'external_tag_id']].map do |target, key, field|
discard_reference(target, key, ->(id) {
"JSON_CONTAINS(#{ row }.tags_json, JSON_OBJECT('#{ field }', #{ id }))"
})
end + [discard_reference('post_versions', 'post_id', ->(id) {
"FIND_IN_SET(#{ id }, REPLACE(#{ row }.parent_post_ids, ' ', ',')) > 0"
})]
when 'tag_versions'
[discard_reference('tag_versions', 'tag_id', ->(id) {
"FIND_IN_SET(#{ id }, REPLACE(#{ row }.parent_tag_ids, ' ', ',')) > 0"
})]
when 'tag_description_versions'
[discard_reference('tag_versions', 'tag_id', ->(id) { "#{ row }.tag_id = #{ id }" })]
when 'material_versions'
[discard_reference('tag_versions', 'tag_id', ->(id) { "#{ row }.tag_id = #{ id }" }),
discard_reference('material_versions', 'material_id', ->(id) {
"#{ row }.parent_id = #{ id }"
})]
when 'tag_group_versions'
[['tag_versions', 'tag_id'], ['tag_group_versions', 'tag_group_id']].map do |target, field|
discard_reference(target, field, ->(id) {
['members', 'placements'].map do |column|
"JSON_CONTAINS(COALESCE(JSON_EXTRACT(#{ row }.#{ column }, " +
"'$**.#{ field }'), JSON_ARRAY()), CAST(#{ id } AS CHAR))"
end.join(' OR ')
})
end
else []
end
end
def self.visible(scope, row = scope.klass.table_name)
scope.where(Arel.sql(sql(scope.klass, row)))
end
def self.current(scope, version_model)
return scope if admin?
table = scope.klass.table_name
keys = KEYS.fetch(version_model.table_name)
matches = keys.map do |key|
column = scope.klass.column_names.include?(key) ? key : 'id'
"current_snapshot.#{ key } = #{ table }.#{ column }"
end.join(' AND ')
boundary = "#{ matches } AND current_snapshot.version_no >= #{ table }.version_no " +
"AND current_snapshot.event_type = 'discard'"
scope = scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ boundary })"))
condition = "#{ matches } AND current_snapshot.version_no = #{ table }.version_no"
scope.where(Arel.sql("NOT EXISTS (SELECT 1 FROM #{ version_model.table_name } " +
"current_snapshot WHERE #{ condition } AND NOT (#{ sql(version_model, 'current_snapshot') }))"))
end
def self.require_current!(record, version_model)
return if admin?
raise Forbidden unless current(record.class.where(id: record.id), version_model).exists?
end
def self.blob_visible?(blob, seen = [])
return true if admin?
return false if seen.include?(blob.id)
seen = seen + [blob.id]
private_versions = MaterialVersion.where(file_blob_id: blob.id)
.where(Arel.sql("NOT (#{ sql(MaterialVersion) })"))
return false if private_versions.exists?
blob.attachments.each do |attachment|
model, key = { 'Post' => [PostVersion, 'post_id'],
'Material' => [MaterialVersion, 'material_id'] }[attachment.record_type]
if model && model.where(key => attachment.record_id, event_type: 'discard').exists?
return false
end
if attachment.record_type == 'ActiveStorage::VariantRecord'
original = ActiveStorage::VariantRecord.find_by(id: attachment.record_id)&.blob
return false unless original && blob_visible?(original, seen)
end
end
true
end
def self.require_many!(records)
return if admin? || records.empty?
ids = records.map(&:id).uniq
raise Forbidden unless visible(records.first.class.where(id: ids)).count == ids.length
end
def self.require!(record)
return record if admin? || visible(record.class.where(id: record.id)).exists?
raise Forbidden
end
# This number deliberately ignores time, identity and content filters to prevent count probes.
def self.population_count(model) = model.unscoped.count
end
+2 -1
ファイルの表示
@@ -3,7 +3,8 @@ class PostVersion < ApplicationRecord
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')
'AND prev.version_no = post_versions.version_no - 1 AND ' +
HistoryAccess.sql(PostVersion, 'prev'))
}
belongs_to :post
+2
ファイルの表示
@@ -1,3 +1,5 @@
class TagDescriptionVersion < ApplicationRecord
include VersionRecord
before_create { SharedResourceIdAllocator.assign!(self) }
end
+2
ファイルの表示
@@ -6,6 +6,8 @@ module VersionRecord
discard: 'discard',
restore: 'restore' }.freeze
def require_history_access! = HistoryAccess.require!(self)
def readonly? = persisted?
included do
+34 -2
ファイルの表示
@@ -6,6 +6,7 @@ class CountsVersionRepr
def initialize(rows, resource:, language_code:, at:, inclusive:)
@rows = rows
HistoryAccess.require_many!(rows)
@at = at
@inclusive = inclusive
@resource = resource
@@ -15,6 +16,7 @@ class CountsVersionRepr
@scripts = master_names(Script)
@tag_versions = {}
load_tag_versions if resource == 'posts'
load_parent_versions if resource == 'tags'
end
def serialise
@@ -47,7 +49,7 @@ class CountsVersionRepr
.uniq
pairs.each_slice(500) do |batch|
placeholders = batch.map { '(?, ?)' }.join(', ')
scope = TagVersion.all
scope = HistoryAccess.visible(TagVersion.all)
if @at
scope = scope.where("created_at #{ @inclusive ? '<=' : '<' } ?", @at)
end
@@ -86,6 +88,31 @@ class CountsVersionRepr
original_created_before: row.original_created_before&.iso8601 }
end
def parent_point(row) = @at || row.created_at
def load_parent_versions
@parent_versions = {}
requests = @rows.flat_map do |row|
row.parent_tag_ids.to_s.split.map { [_1.to_i, parent_point(row)] }
end.uniq
requests.each_slice(100) do |batch|
queries = batch.map do |id, point|
scope = HistoryAccess.visible(TagVersion.where(tag_id: id))
.where('created_at <= ?', point)
.where('NOT EXISTS (SELECT 1 FROM tag_versions newer ' +
'WHERE newer.tag_id = tag_versions.tag_id AND newer.created_at <= ? ' +
'AND newer.version_no > tag_versions.version_no)', point)
quoted = TagVersion.connection.quote(point.iso8601(6))
scope.select("tag_id, name, names, #{ quoted } AS reference_point").to_sql
end
next if queries.empty?
TagVersion.connection.select_all(queries.map { "(#{ _1 })" }.join(' UNION ALL ')).each do |value|
@parent_versions[[value['tag_id'], value['reference_point']]] = value
end
end
end
def tag(row)
recorded_names = names(row.names)
selected = recorded_names.find { _1[:language_code] == @language }
@@ -97,7 +124,12 @@ class CountsVersionRepr
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 } },
parents: row.parent_tag_ids.to_s.split.map do |id|
value = @parent_versions[[id.to_i, parent_point(row).iso8601(6)]]
snapshots = value && json(value['names'])
name = snapshots&.dig(@language, 'name') || value&.fetch('name')
{ id: id.to_i, name: }
end,
deprecated: row.deprecated_at&.iso8601 }
end
end
+5 -3
ファイルの表示
@@ -44,6 +44,7 @@ module TagGroupRepr
end
def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first
end
@@ -56,10 +57,11 @@ module TagGroupRepr
end
def current_version group
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
end
def historical locale, version
HistoryAccess.require!(version)
members = Array(version.members)
placements = Array(version.placements)
tag_references = members.filter_map { |member| member['tag_id'] } +
@@ -153,13 +155,13 @@ module TagGroupRepr
end
def historical_tag_versions ids
TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version|
HistoryAccess.visible(TagVersion.all).where(tag_id: ids.uniq).to_a.index_by do |version|
[version.tag_id, version.version_no]
end
end
def historical_group_versions ids
TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version|
HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: ids.uniq).to_a.index_by do |version|
[version.tag_group_id, version.version_no]
end
end
+1
ファイルの表示
@@ -5,6 +5,7 @@ module WikiPageRepr
module_function
def tag_description locale, description
HistoryAccess.require_current!(description, TagDescriptionVersion)
tag = description.tag
{ 'id' => description.id,
+3 -3
ファイルの表示
@@ -33,8 +33,8 @@ class CountsAxes
def initialize(population)
@population = population
@definition = population.definition
@rows = parse(population.params.fetch('rows', '[]'))
@columns = parse(population.params.fetch('cols', '[]'))
@rows = parse(population.params.fetch('rows', ''))
@columns = parse(population.params.fetch('cols', ''))
@all = rows + columns
raise ArgumentError if all.length > 8 || all.map { _1['field'] }.uniq.length != all.length
end
@@ -81,7 +81,7 @@ class CountsAxes
private
def parse(value)
axes = JSON.parse(value)
axes = QueryState.axes(value)
raise ArgumentError unless axes.is_a?(Array)
axes.each do |axis|
+18 -11
ファイルの表示
@@ -6,16 +6,23 @@ class CountsDefaults
def self.entries(resource)
result = [{ definition: 'history', mode: 'summary', params: {
'rows' => [{ field: 'created_at', grain: 'month' }].to_json,
'cols' => [{ field: 'event_type' }].to_json,
'sort' => 'a0:desc', 'conditions' => '[]' } }]
'rows' => 'created_at:month',
'cols' => 'event_type',
'sort' => 'a0:desc', 'conditions' => '' } }]
result << { definition: 'latest', mode: 'summary', params: {
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
if resource == 'posts'
result += [{ definition: 'latest', mode: 'summary', params: {
'rows' => '[]', 'cols' => '[]', 'conditions' => '[]', 'tags' => '' } },
{ definition: 'latest', mode: 'details', params: {
'columns' => POST_COLUMNS.join(','), 'order' => POST_COLUMNS.join(','),
'children' => 'tags.name,parents.id', 'nested_json' => '0',
'conditions' => '[]', 'tags' => '' } }]
result += [{ definition: 'latest', mode: 'details', params: {
'columns' => POST_COLUMNS.join(' '), 'order' => POST_COLUMNS.join(' '),
'children' => 'tags.name parents.id', 'nested_json' => '0',
'conditions' => '', 'tags' => '' } }]
end
if resource == 'tags'
columns = ['resource_id', 'version_no', 'is_latest', 'name', 'category',
'aliases', 'parents', 'deprecated', 'event_type', 'user', 'created_at']
result << { definition: 'latest', mode: 'details', params: {
'columns' => columns.join(' '), 'order' => columns.join(' '),
'children' => 'aliases.name parents.name', 'nested_json' => '0' } }
end
result
end
@@ -26,8 +33,8 @@ class CountsDefaults
entry[:mode] == params.fetch('mode', 'summary')
end&.fetch(:params) || {}
if settings.key?('sort') && params.key?('rows')
actual = JSON.parse(params['rows'])
expected = JSON.parse(settings.fetch('rows'))
actual = QueryState.axes(params['rows'])
expected = QueryState.axes(settings.fetch('rows'))
compatible = actual.is_a?(Array) && actual.first.is_a?(Hash) &&
actual.first['field'] == expected.first['field'] &&
actual.first.fetch('grain', 'month') == expected.first['grain']
+5 -3
ファイルの表示
@@ -20,10 +20,11 @@ class CountsExportDownload
def self.prepare(exporter, owner, progress: nil, check: nil, identifier: nil)
FileUtils.mkdir_p(directory, mode: 0o700)
authority = HistoryAccess.authority
slot = acquire_slot
begin
generate(exporter, owner, progress:, check:, identifier:)
generate(exporter, owner, progress:, check:, identifier:, authority:)
ensure
slot.close
end
@@ -40,7 +41,7 @@ class CountsExportDownload
raise Busy
end
def self.generate(exporter, owner, progress: nil, check: nil, identifier: nil)
def self.generate(exporter, owner, progress: nil, check: nil, identifier: nil, authority: HistoryAccess.authority)
FileUtils.mkdir_p(directory, mode: 0o700)
Dir.glob(directory.join('counts-*.export')).each do |path|
File.unlink(path) if File.mtime(path) < Time.current - EXPIRY
@@ -80,7 +81,7 @@ class CountsExportDownload
path = finished_path
token = verifier.generate({ 'file' => File.basename(path),
'resource' => exporter.resource, 'format' => exporter.format,
'owner' => owner }, expires_in: EXPIRY, purpose: 'counts_export')
'owner' => owner, **authority }, expires_in: EXPIRY, purpose: 'counts_export')
complete = true
token
ensure
@@ -92,6 +93,7 @@ class CountsExportDownload
def self.find(token, owner)
metadata = verifier.verified(token, purpose: 'counts_export')
return unless metadata.is_a?(Hash) && metadata['owner'] == owner
return unless HistoryAccess.authorised_artifact?(metadata)
return unless metadata['file']&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
return unless CountsResourceDefinition::DEFINITIONS.key?(metadata['resource'])
return unless ['json', 'csv'].include?(metadata['format'])
+19
ファイルの表示
@@ -103,6 +103,7 @@ class CountsExportJobs
'language' => language, 'resource' => exporter.resource,
'format' => exporter.format, 'status' => 'queued',
'processed' => 0, 'total' => nil,
**HistoryAccess.authority,
'expires_at' => (Time.current + CountsExportDownload::EXPIRY).to_f }
write(state)
verifier.generate({ 'id' => id, 'owner' => owner },
@@ -126,6 +127,8 @@ class CountsExportJobs
state = read(id)
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
return unless HistoryAccess.authorised_artifact?(state)
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
remove_download(state)
@@ -182,6 +185,22 @@ class CountsExportJobs
end
def self.perform(state)
actor = state['actor_id'] && User.find_by(id: state['actor_id'])
actor = nil unless state['admin'] && actor&.admin? && !actor.banned?
HistoryAccess.set(user: actor) do
unless HistoryAccess.authorised_artifact?(state)
synchronise do
current = read(state.fetch('id'))
current['status'] = 'failed'
write(current)
end
return
end
perform_authorised(state)
end
end
def self.perform_authorised(state)
id = state.fetch('id')
token = nil
begin
+21 -5
ファイルの表示
@@ -21,7 +21,7 @@ class CountsExportProjection
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
'names' => NAME_FIELDS,
'aliases' => ['languageCode', 'name', 'scriptCode'],
'parents' => ['id'] }.freeze
'parents' => ['id', 'name'] }.freeze
def self.default_columns(resource)
fields = { 'posts' => ['title', 'url', 'tags', 'parents', 'video_ms'],
@@ -56,7 +56,7 @@ class CountsExportProjection
CountsQuery::COLUMNS.fetch(resource)
end
defaults = summary ? allowed : self.class.default_columns(resource)
@columns = params.fetch('columns', defaults.join(',')).split(',')
@columns = QueryState.list(params.fetch('columns', defaults.join(' ')))
raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns
@headers = JSON.parse(params.fetch('headers', JSON.generate(@columns)))
@@ -64,8 +64,8 @@ class CountsExportProjection
@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|
@order = QueryState.list(params.fetch('order', ''))
@sorts = QueryState.list(params.fetch('child_sort', '')).map do |entry|
parts = entry.split(':', -1)
raise ArgumentError unless parts.length == 2
@@ -74,12 +74,28 @@ class CountsExportProjection
[path, direction]
end
@selected = params.key?('children') ? params['children'].split(',').to_set :
@selected = params.key?('children') ? QueryState.list(params['children']).to_set :
self.class.default_children(resource).to_set
valid = CHILDREN.flat_map do |key, fields|
paths(fields, key)
end
raise ArgumentError if @selected && (@selected.to_a - valid).any?
prune = lambda do |fields, prefix|
fields.each do |key|
path = "#{ prefix }.#{ key }"
children = fields_for(key)
next if children.empty?
prune.call(children, path)
@selected.delete(path) unless children.any? { @selected.include?("#{ path }.#{ _1 }") }
end
end
CHILDREN.each { |key, fields| prune.call(fields, key) }
pairs = @columns.zip(@headers).select do |key, _header|
!CHILDREN.key?(key) || CHILDREN[key].any? { @selected.include?("#{ key }.#{ _1 }") }
end
@columns = pairs.map(&:first)
@headers = pairs.map(&:last)
allowed_sorts = valid.map do |path|
parts = path.split('.')
field = parts.pop
+4 -4
ファイルの表示
@@ -14,7 +14,7 @@ class CountsExporter
return
end
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
raise ArgumentError if (params.keys - allowed).any?
QueryState.validate!(params, allowed)
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@@ -65,8 +65,8 @@ class CountsExporter
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',
children: @params['children']&.split(' '),
field_order: @QueryState.list(params.fetch('order', '')), timezone: 'UTC',
count: @mode == 'summary' ? page[:group_count] : page[:count],
totals: page[:totals], coverage: page[:coverage] }
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
@@ -103,7 +103,7 @@ class CountsExporter
end
def sort_metadata(value)
value.split(',').map do |entry|
value.split(' ').map do |entry|
key, direction = entry.split(':', 2)
{ key:, direction: }
end
+2 -2
ファイルの表示
@@ -2,10 +2,10 @@
class CountsPivotLayout
def self.depths(keys, sorts, measures: true)
return [] if keys.length < 2 || sorts.first&.first == 'count' ||
sorts.first&.first&.start_with?('measure_')
sorts.first&.first&.match?(/\Am[0-9]+\z/)
relevant = sorts.map(&:first).select { keys.include?(_1) || (measures && (_1 == 'count' ||
_1.start_with?('measure_'))) }
_1.match?(/\Am[0-9]+\z/))) }
order = (relevant + keys).uniq
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
end
+6 -4
ファイルの表示
@@ -14,6 +14,7 @@ class CountsPopulation
@scope = definition.model.all
make_population
@scope = HistoryAccess.visible(@scope, 'v')
apply_filters
rescue TZInfo::InvalidTimezoneIdentifier
raise ArgumentError
@@ -62,7 +63,8 @@ class CountsPopulation
@scope = @scope.from("#{ table } v").select('v.*')
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
'AND previous.version_no = v.version_no - 1')
'AND previous.version_no = v.version_no - 1 AND ' +
HistoryAccess.sql(definition.model, 'previous'))
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
unless history
point = timestamp(params['at']) || at
@@ -92,11 +94,11 @@ class CountsPopulation
@scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i)
end
apply_tags if params['tags'].present? || params['tag'].present?
conditions = JSON.parse(params.fetch('conditions', '[]'))
conditions = QueryState.records(params, 'conditions')
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
conditions.each { apply_condition(_1) }
drills = JSON.parse(params.fetch('drill', '[]'))
drills = QueryState.records(params, 'drill')
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
drills.each do |entry|
@@ -190,7 +192,7 @@ class CountsPopulation
return unless params['tags'].present?
locale = Locale.find_by!(code: params.fetch('locale', 'ja'))
refs = PostTagSearch.parse_references(params['tag_refs'], params['tags'].split)
refs = PostTagSearch.parse_references(params, params['tags'].split)
raw = params['tags']
literals = PostTagSearch.literals(locale, raw, references: refs)
predicates = literals.map do |literal|
+3 -2
ファイルの表示
@@ -20,6 +20,7 @@ class CountsQuery
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
@params = params
@latest_expression = latest_expression
scope = HistoryAccess.visible(scope)
@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?
@@ -33,7 +34,7 @@ class CountsQuery
@limit = integer('limit', 20)
raise ArgumentError if limit > 100 || page > 1_000_000
columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',')
columns = QueryState.list(params.fetch('columns', COLUMNS.fetch(resource).join(' ')))
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
raise ArgumentError
end
@@ -92,7 +93,7 @@ class CountsQuery
value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc')
return [] if value.empty?
values = value.split(',', -1).map { _1.split(':', -1) }
values = QueryState.list(value).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)
+9 -5
ファイルの表示
@@ -46,11 +46,12 @@ class CountsStateQuery
# 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)
@post_start = HistoryAccess.visible(PostVersion.all).minimum(:created_at)
@tag_start = HistoryAccess.visible(TagVersion.all).minimum(:created_at)
@history_loaded = true
end
result = { page: @page, limit: @limit, at: @at.iso8601(6),
result = { population_count: HistoryAccess.population_count(
@resource == 'posts' ? PostVersion : TagVersion), 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) } }
@@ -131,7 +132,10 @@ class CountsStateQuery
end
def state_sql(table, boundary, live: false)
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) }"
model = { 'post_versions' => PostVersion, 'tag_versions' => TagVersion,
'nico_tag_versions' => NicoTagVersion }.fetch(table)
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) } " +
"AND (#{ HistoryAccess.sql(model, 'v') })"
live ? "#{ sql } AND v.event_type <> 'discard'" : sql
end
@@ -350,7 +354,7 @@ class CountsStateQuery
def order_sql(expressions, default, final)
raw = @params.fetch('sort', default)
sorts = raw.empty? ? [] : raw.split(',', -1).map { _1.split(':', -1) }
sorts = raw.empty? ? [] : QueryState.list(raw).map { _1.split(':', -1) }
raise ArgumentError if sorts.map(&:first).uniq.length != sorts.length
raise ArgumentError if sorts.length > expressions.length
+21 -9
ファイルの表示
@@ -7,20 +7,20 @@ class CountsTableExport
attr_reader :format, :resource
def initialize(params)
raise ArgumentError if (params.keys - CountsTableQuery::KEYS - EXTRA).any?
QueryState.validate!(params, CountsTableQuery::KEYS + EXTRA + ['measures'])
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
params = CountsDefaults.for(params).merge(params)
unless params.key?('children')
params = params.merge('children' => CountsExportProjection.default_children(
params.fetch('resource', 'posts')).join(','))
params.fetch('resource', 'posts')).join(' '))
end
@params = params
@labels = JSON.parse(params.fetch('labels', '{}'))
raise ArgumentError unless @labels.is_a?(Hash)
@selected = params.key?('children') ? params['children'].split(',') : nil
@order = params.fetch('order', '').split(',')
@child_sort = params.fetch('child_sort', '').split(',').map { _1.split(':', -1) }
@selected = params.key?('children') ? QueryState.list(params['children']) : nil
@order = QueryState.list(params.fetch('order', ''))
@child_sort = QueryState.list(params.fetch('child_sort', '')).map { _1.split(':', -1) }
@child_sort.each do |path, direction, *extra|
raise ArgumentError if extra.any? || !['asc', 'desc'].include?(direction)
raise ArgumentError unless path&.match?(/\A[a-zA-Z_]+(?:\.[a-zA-Z]+)*\/[a-zA-Z]+\z/)
@@ -29,12 +29,13 @@ class CountsTableExport
@resource = params.fetch('resource', 'posts')
raise ArgumentError unless ['json', 'csv'].include?(format)
@query = CountsTableQuery.new(params.slice(*CountsTableQuery::KEYS)
@query = CountsTableQuery.new(params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
key == 'measures' || QueryState.valid_key?(key) }
.merge('page' => '1', 'limit' => '100'))
@summary = @query.mode == 'summary'
default_columns = @summary ? @query.axes.all.map { _1.fetch('field') } + ['count'] :
CountsExportProjection.default_columns(resource)
@columns = params.fetch('columns', default_columns.join(',')).split(',')
@columns = QueryState.list(params.fetch('columns', default_columns.join(' ')))
default_headers = @columns.map { @labels.fetch(_1, _1) }
@headers = JSON.parse(params.fetch('headers', default_headers.to_json))
raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) }
@@ -66,7 +67,8 @@ class CountsTableExport
unless started
metadata = page.except(:versions, :cells, :axis_labels, :column_values,
:row_headers, :column_headers, :pivot_totals).merge(
resource:, filters: @params.slice(*CountsTableQuery::KEYS),
resource:, filters: @params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
key == 'measures' || QueryState.valid_key?(key) },
fields: @query.population.definition.metadata,
columns: @columns, column_labels: @headers,
order: @params['order'], children: @params['children'],
@@ -138,13 +140,23 @@ class CountsTableExport
end
def total_label(header)
@labels.fetch(header[:kind] == 'grand_total' ? 'grandTotal' : 'subtotal', '')
key = header[:kind] == 'grand_total' ?
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal'
@labels.fetch(key, '')
end
def pivot_row_labels(header, page)
values = header[:values].each_with_index.map { |value, index|
summary_value(value, index, page)
}
if header[:kind] == 'data' && @previous_pivot_row&.fetch(:kind) == 'data'
values.each_index do |index|
if header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1)
values[index] = ''
end
end
end
@previous_pivot_row = header
values << total_label(header) unless header[:kind] == 'data'
leading = [@query.axes.rows.length, @query.axes.columns.any? ? 1 : 0].max
Array.new(leading) { values[_1] || '' }
+27 -8
ファイルの表示
@@ -5,11 +5,11 @@ class CountsTableQuery
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale'].freeze
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale', 'measures', 'column_order'].freeze
attr_reader :population, :axes, :page, :limit, :mode
def initialize(params)
raise ArgumentError if (params.keys - KEYS).any?
QueryState.validate!(params, KEYS + ['measures'])
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
params = CountsDefaults.for(params).slice(*KEYS).merge(params)
@@ -24,14 +24,14 @@ class CountsTableQuery
raise ArgumentError unless page.between?(1, 1_000_000) && limit.between?(1, 100)
raise ArgumentError unless ['summary', 'details'].include?(mode)
@sort = params.fetch('sort', '').split(',').map { _1.split(':', -1) }
@sort = QueryState.list(params.fetch('sort', '')).map { _1.split(':', -1) }
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
@definition.fields.keys + ['is_latest'] +
(@definition.resource == 'posts' ? ['tags', 'parents'] : []) +
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
@sort.each do |key, direction, *extra|
measure = mode == 'summary' && key&.match?(/\Ameasure_[0-9a-f]+\z/)
measure = mode == 'summary' && key&.match?(/\Am[0-9]{1,2}\z/)
measure_values(key) if measure
if extra.any? || (!allowed.include?(key) && !measure) || !['asc', 'desc'].include?(direction)
raise ArgumentError
@@ -46,7 +46,8 @@ class CountsTableQuery
end
def metadata
{ page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
{ population_count_scope: 'all_recorded_versions', population_count: HistoryAccess.population_count(@definition.model),
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
definition: population.history ? 'history' : 'latest',
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
fields: @definition.metadata,
@@ -76,7 +77,7 @@ class CountsTableQuery
measure_joins = []
extras = []
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
if key == 'count' || key.start_with?('measure_')
if key == 'count' || key.match?(/\Am[0-9]+\z/)
values = key == 'count' ? [] : measure_values(key)
relation = measure_relation(values)
matches = row_keys.map { "grouped.#{ _1 } <=> sort#{ index }.#{ _1 }" }
@@ -125,7 +126,12 @@ class CountsTableQuery
row_values = records.map { |record| row_keys.map { record[_1] } }.uniq
row_depths = CountsPivotLayout.depths(row_keys, @sort)
column_keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
column_depths = CountsPivotLayout.depths(column_keys, @sort, measures: false)
column_sort = @sort
if @params.key?('column_order') && column_keys.any? &&
!(@sort.first&.first == 'count' || @sort.first&.first&.match?(/\Am[0-9]+\z/))
column_sort = [[column_keys.first, 'asc']] + @sort
end
column_depths = CountsPivotLayout.depths(column_keys, column_sort, measures: false)
row_headers = CountsPivotLayout.headers(row_values, row_depths,
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?)
if axes.rows.empty? && axes.columns.any?
@@ -197,6 +203,15 @@ class CountsTableQuery
order = directions.map { |key, direction|
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
}.join(', ')
custom = QueryState.records(@params, 'column_order')
if custom.any?
raise ArgumentError unless custom.map { _1['value'] }.uniq.length == custom.length
cases = custom.each_with_index.map do |record, index|
"WHEN #{ keys.first } <=> #{ @connection.quote(record['value']) } THEN #{ index }"
end.join(' ')
order = "CASE #{ cases } ELSE #{ custom.length } END, #{ order }"
end
values = @connection.select_rows(<<~SQL)
#{ prefix }
SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
@@ -225,7 +240,11 @@ class CountsTableQuery
end
def measure_values(key)
value = JSON.parse([key.delete_prefix('measure_')].pack('H*'))
records = QueryState.records(@params, 'measures').select { _1['key'] == key }
raise ArgumentError if records.empty?
raise ArgumentError unless records.map { _1['axis'] }.sort == (0...records.length).to_a
value = records.sort_by { _1['axis'] }.map { _1['value'] }
unless (value.is_a?(Array) && value.length <= axes.columns.length &&
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
raise ArgumentError
+2 -2
ファイルの表示
@@ -1,9 +1,9 @@
# Shared literal parsing and reference resolution for current and snapshot post search.
class PostTagSearch
def self.parse_references(raw, literals)
return nil if raw.blank?
return nil unless raw.key?('tag_refs')
references = JSON.parse(raw)
references = QueryState.records(raw, 'tag_refs')
valid = references.is_a?(Array) && references.all? do |reference|
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
+88
ファイルの表示
@@ -0,0 +1,88 @@
# Public query grammar shared with frontend/src/lib/queryState.ts.
class QueryState
SCHEMAS = {
'conditions' => ['field', 'operator', 'value', 'end', 'blank'],
'drill' => ['field', 'grain', 'value', 'value_type'],
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
'column_order' => ['value', 'value_type'],
'measures' => ['key', 'axis', 'value', 'value_type'] }.freeze
def self.list(value)
return [] if value.nil? || value == ''
raise ArgumentError unless value.is_a?(String) && value.bytesize <= 32_768
values = value.split(/ /, -1)
raise ArgumentError if values.length > 200 || values.any?(&:empty?) || values.uniq != values
values
end
def self.valid_key?(key)
root, index, field, extra = key.split('.', -1)
!extra && SCHEMAS.key?(root) && index&.match?(/\A(?:0|[1-9][0-9]?)\z/) &&
SCHEMAS[root].include?(field)
end
def self.validate!(params, allowed)
raise ArgumentError if params.length > 1000
params.each do |key, value|
raise ArgumentError unless allowed.include?(key) || valid_key?(key)
raise ArgumentError unless value.is_a?(String) && value.bytesize <= 131_072
end
SCHEMAS.each_key { records(params, _1) }
end
def self.records(params, key)
records = {}
params.each do |name, value|
next unless name.start_with?("#{ key }.")
raise ArgumentError unless params.key?(key) && valid_key?(name)
_root, index, field = name.split('.')
record = records[index.to_i] ||= {}
if ['blank', 'negative', 'missing'].include?(field)
raise ArgumentError unless ['0', '1'].include?(value)
value = value == '1'
elsif ['tag_id', 'external_tag_id', 'axis'].include?(field)
raise ArgumentError unless value.match?(/\A[0-9]{1,18}\z/)
value = value.to_i
end
record[field] = value
end
raise ArgumentError if params.key?(key) && params[key] != ''
raise ArgumentError unless records.keys.sort == (0...records.length).to_a
records.sort.map do |_index, record|
type = record.delete('value_type')
if ['drill', 'measures', 'column_order'].include?(key)
raise ArgumentError unless ['null', 'string', 'number', 'boolean'].include?(type)
raise ArgumentError unless type == 'null' || record.key?('value')
value = record['value']
record['value'] = case type
when 'null' then nil
when 'number'
raise ArgumentError unless value&.match?(/\A-?[0-9]{1,18}\z/)
Integer(value, 10)
when 'boolean'
raise ArgumentError unless ['true', 'false'].include?(value)
value == 'true'
else value
end
end
record
end
end
def self.axes(value)
list(value).map do |token|
field, grain, extra = token.split(':', -1)
raise ArgumentError if extra || !field.match?(/\A[a-z_]+\z/) || grain == ''
grain ? { 'field' => field, 'grain' => grain } : { 'field' => field }
end
end
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
Rails.application.config.to_prepare do
[ActiveStorage::Blobs::ProxyController, ActiveStorage::Blobs::RedirectController,
ActiveStorage::Representations::ProxyController,
ActiveStorage::Representations::RedirectController, ActiveStorage::DiskController].each do |controller|
controller.include(HistoryStorageAccess) unless controller < HistoryStorageAccess
end
end
+15
ファイルの表示
@@ -1,5 +1,20 @@
# frontend/AGENTS.md
## Mandatory security and URL contracts
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
## Scope
These rules apply to work under `frontend/`.
+14 -3
ファイルの表示
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { createElement, Suspense, lazy, useEffect,
import { createElement, Suspense, lazy, useCallback, useEffect, useRef,
useLayoutEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createBrowserRouter,
@@ -267,8 +267,19 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
? resolve ({ location, params: route.params, locale, targetLocale })
: switchLocalePath (location, targetLocale)
}
const [user, setUser] = useState<User | null> (
const [user, setUserState] = useState<User | null> (
() => client.getQueryData<User> (sessionKeys.current) ?? null)
const authority = useRef (user)
const setUser = useCallback<Dispatch<SetStateAction<User | null>>> (action => {
const next = typeof action === 'function' ? action (authority.current) : action
if (next?.id !== authority.current?.id || next?.role !== authority.current?.role)
{
client.cancelQueries ()
client.removeQueries ({ predicate: query => query.queryKey[0] !== 'session' })
}
authority.current = next
setUserState (next)
}, [client])
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
@@ -317,7 +328,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
}
}) ()
return () => { cancelled = true }
}, [])
}, [setUser])
useEffect (() => {
if (!(user))
+79
ファイルの表示
@@ -0,0 +1,79 @@
import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor,
closestCenter, useSensor, useSensors } from '@dnd-kit/core'
import { SortableContext, arrayMove, horizontalListSortingStrategy,
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useClientMotion } from '@/lib/useClientMotion'
import type { ReactNode } from 'react'
const SortableColumns = ({ ids, fixed = [], name, onChange, children }: {
ids: string[]; fixed?: string[]; name: (id: string) => { text: string; language: string }
onChange: (ids: string[]) => void; children: (ids: string[]) => ReactNode },
) => {
const [preview, setPreview] = useState<string[] | null> (null)
const [active, setActive] = useState<string | null> (null)
const latest = useRef<string[] | null> (null)
const lastOver = useRef<string | null> (null)
const motion = useClientMotion ()
const sensors = useSensors (
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
useSensor (KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }))
const move = (current: string[], from: string, to: string) => {
if (fixed.includes (from) || fixed.includes (to)) return current
const source = current.indexOf (from)
const target = current.indexOf (to)
return source < 0 || target < 0 ? current : arrayMove (current, source, target)
}
const reset = () => {
latest.current = null
lastOver.current = null
setPreview (null)
setActive (null)
}
return <DndContext sensors={sensors} collisionDetection={closestCenter}
accessibility={{ announcements: {
onDragStart: event => name (String (event.active.id)).text,
onDragOver: event => event.over == null ? undefined : name (String (event.over.id)).text,
onDragMove: () => undefined, onDragEnd: () => undefined,
onDragCancel: () => undefined },
screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }}
onDragStart={event => {
latest.current = ids
setPreview (ids)
setActive (String (event.active.id))
}} onDragOver={event => {
if (event.over == null || event.active.id === event.over.id) return
const target = String (event.over.id)
if (target === lastOver.current) return
latest.current = move (latest.current ?? ids, String (event.active.id), target)
lastOver.current = target
setPreview (latest.current)
}} onDragCancel={reset} onDragEnd={event => {
if (event.over != null)
{
const target = String (event.over.id)
const current = latest.current ?? ids
onChange (target === lastOver.current || target === String (event.active.id)
? current : move (current, String (event.active.id), target))
}
reset ()
}}>
<SortableContext items={preview ?? ids} strategy={horizontalListSortingStrategy}>
{children (preview ?? ids)}
</SortableContext>
{typeof document !== 'undefined' && createPortal (<DragOverlay
dropAnimation={motion.reduced ? null : {
duration: motion.duration ('drop'), easing: motion.easing }}>
{active != null && <div lang={name (active).language}
className="rounded border border-blue-500 bg-white p-2 text-slate-900 shadow-lg
dark:border-blue-400 dark:bg-slate-800 dark:text-slate-100">{name (active).text}</div>}
</DragOverlay>, document.body)}
</DndContext>
}
export default SortableColumns
+19 -12
ファイルの表示
@@ -4,30 +4,28 @@ import { CSS } from '@dnd-kit/utilities'
import { useClientMotion } from '@/lib/useClientMotion'
import { cn } from '@/lib/utils'
import type { ReactNode } from 'react'
import type { CSSProperties, ReactNode } from 'react'
type Name = { text: string; language: string }
const SortableItem = ({ id, name, fixed, children, moveGroup, handleId }: {
const SortableItem = ({ id, name, fixed, children, moveGroup, handleId, renderShell }: {
id: string; name: Name; fixed?: boolean; children: ReactNode
moveGroup: (direction: number) => void; handleId: string },
moveGroup: (direction: number) => void; handleId: string
renderShell?: (shell: { ref: (node: HTMLElement | null) => void
style: CSSProperties; handle: ReactNode; dragging: boolean }) => ReactNode },
) => {
const motion = useClientMotion ()
const item = useSortable ({ id, disabled: fixed,
transition: { duration: motion.duration ('reorder'), easing: motion.easing },
animateLayoutChanges: options => !motion.reduced
&& defaultAnimateLayoutChanges ({ ...options, wasDragging: true }) })
return <div ref={item.setNodeRef}
style={{ transform: CSS.Transform.toString (item.transform), transition: item.transition }}
className={cn ('relative max-w-full rounded border border-slate-300 bg-white',
'text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100',
item.isDragging && 'opacity-30 ring-2 ring-blue-500 dark:ring-blue-400')}>
<div className="flex min-w-0 items-start gap-1 px-1">
{fixed ? <span className="w-6 shrink-0"/> : <button type="button"
const handle = fixed ? <span className="w-6 shrink-0"/> : <button type="button"
id={handleId} ref={item.setActivatorNodeRef} {...item.attributes} {...item.listeners}
aria-label={name.text} lang={name.language}
aria-keyshortcuts="Space ArrowUp ArrowDown Enter Escape Alt+ArrowLeft Alt+ArrowRight"
aria-keyshortcuts={renderShell == null
? 'Space ArrowUp ArrowDown Enter Escape Alt+ArrowLeft Alt+ArrowRight'
: 'Space ArrowLeft ArrowRight Enter Escape'}
className="mt-1 w-6 shrink-0 cursor-grab touch-none rounded
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
active:cursor-grabbing dark:focus-visible:ring-blue-400"
@@ -41,7 +39,16 @@ const SortableItem = ({ id, name, fixed, children, moveGroup, handleId }: {
moveGroup (event.key === 'ArrowLeft' ? -1 : 1)
}
else item.listeners?.onKeyDown?.(event)
}}><span aria-hidden>⠿</span></button>}
}}><span aria-hidden>⠿</span></button>
const style = { transform: CSS.Transform.toString (item.transform), transition: item.transition }
if (renderShell != null)
return renderShell ({ ref: item.setNodeRef, style, handle, dragging: item.isDragging })
return <div ref={item.setNodeRef} style={style}
className={cn ('relative max-w-full rounded border border-slate-300 bg-white',
'text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100',
item.isDragging && 'opacity-30 ring-2 ring-blue-500 dark:ring-blue-400')}>
<div className="flex min-w-0 items-start gap-1 px-1">
{handle}
<div className="min-w-0 flex-1 py-1">{children}</div>
</div>
</div>
+5 -3
ファイルの表示
@@ -3,12 +3,14 @@ import { useEffect, useState } from 'react'
import DateTimeField from '@/components/common/DateTimeField'
import { Button } from '@/components/ui/button'
import { countsEventTypes, countsFieldLabel, countsFilterFields,
readCountsJSON, useCountsDefinition }
useCountsDefinition }
from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, encodeCountState } from '@/lib/queryState'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
@@ -65,8 +67,8 @@ const CountsAdditionalConditions = ({ params, update }: {
const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition ()
const dialogue = useDialogue ()
const conditions = readCountsJSON<CountsCondition[]> (params, 'conditions', [])
const write = (next: CountsCondition[]) => update ('conditions', JSON.stringify (next))
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
const write = (next: CountsCondition[]) => update ('conditions', encodeCountState ('conditions', next))
const edit = (index?: number) => {
const initial = index == null ? undefined : conditions[index]
const save = (condition: CountsCondition) => {
+7 -5
ファイルの表示
@@ -1,13 +1,15 @@
import LocalisedText from '@/components/LocalisedText'
import SortableList from '@/components/common/SortableList'
import { countsAxisFields, countsAxisLabel, countsFilterFields,
readCountsJSON, useCountsDefinition }
useCountsDefinition }
from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
import { cn, inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, encodeCountState } from '@/lib/queryState'
import type { CountsAxis } from '@/lib/countsDefinitions'
@@ -19,8 +21,8 @@ const CountsAxes = ({ params, update }: {
const locale = useLocale ()
const motion = useClientMotion ()
const available = countsAxisFields (definition, params.get ('definition') === 'history')
const rows = readCountsJSON<CountsAxis[]> (params, 'rows', [])
const cols = readCountsJSON<CountsAxis[]> (params, 'cols', [])
const rows = readCountState<CountsAxis[]> (params, 'rows', [])
const cols = readCountState<CountsAxis[]> (params, 'cols', [])
const axes = [...rows, ...cols]
const groups = {
available: available.map (field => field.key).filter (id => !axes.some (
@@ -33,7 +35,7 @@ const CountsAxes = ({ params, update }: {
}
const commit = (next: Record<string, string[]>) => {
for (const group of ['rows', 'cols'] as const)
update (group, JSON.stringify (next[group].map (field => axes.find (
update (group, encodeCountState (group, next[group].map (field => axes.find (
axis => axis.field === field) ?? { field,
...(countsFilterFields (definition)[field] === 'datetime' ? { grain: 'month' } : { }) })))
}
@@ -73,7 +75,7 @@ const CountsAxes = ({ params, update }: {
{countsFilterFields (definition)[id] === 'datetime' && <select
className={inputClass (false, 'w-auto py-0')}
value={axis?.grain ?? 'month'} aria-label={message ('grain').text}
onChange={event => update (group, JSON.stringify (
onChange={event => update (group, encodeCountState (group,
(group === 'rows' ? rows : cols).map (item => item.field === id
? { ...item, grain: event.target.value } : item)))}>
{definition.grains.map (grain => <option key={grain} value={grain}
+8 -6
ファイルの表示
@@ -12,11 +12,13 @@ import { Button } from '@/components/ui/button'
import { countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
import { countsNestedIsScalar } from '@/lib/countsDisplay'
import { readCountsJSON, useCountsDefinition } from '@/lib/countsDefinitions'
import { useCountsDefinition } from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, setCountState } from '@/lib/queryState'
import type { ReactNode } from 'react'
import type { CountsResource } from '@/lib/counts'
@@ -59,7 +61,7 @@ const CountsControls = (
const next = new URLSearchParams (previous)
const columns = selectedCountColumns (resource, previous, definition)
.filter (column => column !== 'thumbnail')
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (','))
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (' '))
return next
})
}, [thumbnail, resource, definition])
@@ -79,16 +81,16 @@ const CountsControls = (
const update = (key: string, value: string) => setDraft (previous => {
const next = new URLSearchParams (previous)
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
next.set (key, value)
setCountState (next, key, value)
if (key !== 'mode') return next
const resolved = normaliseCountsParams (resource, next, definition)
const axes = [...readCountsJSON<unknown[]> (resolved, 'rows', []),
...readCountsJSON<unknown[]> (resolved, 'cols', [])]
const axes = [...readCountState<unknown[]> (resolved, 'rows', []),
...readCountState<unknown[]> (resolved, 'cols', [])]
const allowed = value === 'summary'
? ['count', ...axes.map ((_, index) => `a${index}`)]
: fieldsForCounts (resource, definition).map (field => field.key)
if (readCountSort (resolved.get ('sort')).some (sort => !allowed.includes (sort.key)
&& !(value === 'summary' && /^measure_[0-9a-f]+$/.test (sort.key))))
&& !(value === 'summary' && /^m[0-9]{1,2}$/.test (sort.key))))
next.delete ('sort')
return normaliseCountsParams (resource, next, definition)
})
+25 -9
ファイルの表示
@@ -2,7 +2,7 @@ import { useId, useState } from 'react'
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
import SortableList from '@/components/common/SortableList'
import { countChildPaths, fieldsForCounts, orderedCountFields,
import { fieldsForCounts, orderedCountFields,
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
import { useCountsDefinition } from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
@@ -26,7 +26,8 @@ const FieldBranch = ({ field, path, enabled, selected, params, select, update }:
return <>
<div className="flex items-center gap-1">
<label className="inline-flex items-center gap-1">
<input type="checkbox" checked={checked} disabled={!enabled}
<input type="checkbox" checked={checked} disabled={!enabled || (field.children != null
&& !field.children.some (child => selected.has (`${path}.${child.key}`)))}
onChange={event => select (path, event.target.checked)}/>
{label (field.label)}
</label>
@@ -43,7 +44,7 @@ const FieldBranch = ({ field, path, enabled, selected, params, select, update }:
{field.children != null && <AnimatedCollapse open={open} id={id}>
<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}/>
enabled={enabled} selected={selected} select={select} update={update}/>
</div>
</AnimatedCollapse>}
</>
@@ -65,9 +66,9 @@ const FieldGroup = ({ fields, path, params, enabled, selected, select, update }:
renderGroup={(_, content) => content}
onChange={groups => {
const current = fields.map (field => `${prefix}${field.key}`)
const other = (params.get ('order') ?? '').split (',')
const other = (params.get ('order') ?? '').split (' ')
.filter (key => key && !current.includes (key))
update ('order', [...other, ...groups.items.map (key => `${prefix}${key}`)].join (','))
update ('order', [...other, ...groups.items.map (key => `${prefix}${key}`)].join (' '))
}} renderItem={key => {
const field = fields.find (field => field.key === key)!
return <FieldBranch field={field} path={`${prefix}${key}`} enabled={enabled}
@@ -101,13 +102,28 @@ const CountsFieldTree = (
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 (','))
.map (field => field.key).join (' '))
return
}
if (checked) selected.add (path)
if (checked)
{
const parts = path.split ('.')
for (let index = 1; index < parts.length; ++index)
selected.add (parts.slice (0, index + 1).join ('.'))
}
else selected.delete (path)
update ('children', countChildPaths (fields).filter (
key => key.includes ('.') && selected.has (key)).join (','))
const nextParams = new URLSearchParams (params)
nextParams.set ('children', [...selected].filter (key => key.includes ('.')).join (' '))
const children = selectedCountChildren (nextParams)
update ('children', [...children].join (' '))
const nextRoots = new Set (roots)
const ancestor = fields.find (field => path.startsWith (`${rootPath (field.key)}.`))
if (checked && ancestor != null) nextRoots.add (ancestor.key)
for (const field of fields)
if (field.children != null && !field.children.some (child =>
children.has (`${rootPath (field.key)}.${child.key}`))) nextRoots.delete (field.key)
update (selectionKey, fields.filter (field => nextRoots.has (field.key))
.map (field => field.key).join (' '))
}
const visibleFields = parent === ''
? fields.filter (field => field.key !== 'thumbnail') : fields
+13 -3
ファイルの表示
@@ -1,3 +1,4 @@
import SortableColumns from '@/components/common/SortableColumns'
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import CountsRowNumber from '@/components/counts/CountsRowNumber'
@@ -33,7 +34,6 @@ const CountsResults = (
const dialogue = useDialogue ()
const columns = selectedCountColumns (resource, params, definition)
const rowNumbers = params.get ('row_numbers') === '1'
const tableColumns = rowNumbers ? ['row_number', ...columns] : columns
const fields = fieldsForCounts (resource, definition)
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
const sort = (key: string, additive: boolean) =>
@@ -104,7 +104,15 @@ const CountsResults = (
default: return value
}
}
return (
return <SortableColumns ids={columns} fixed={['thumbnail']}
name={key => message (fields.find (field => field.key === key)!.label)}
onChange={next => {
const all = fields.map (field => field.key)
const other = (params.get ('order') ?? '').split (' ').filter (key => !all.includes (key))
update ('order', [...next, ...all.filter (key => !next.includes (key)), ...other].join (' '))
}}>{columns => {
const tableColumns = rowNumbers ? ['row_number', ...columns] : columns
return (
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
{tableColumns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
<CountsSelectableTable family={resource}
@@ -112,7 +120,7 @@ const CountsResults = (
copyTable={() => countsDetailClipboardTable (data.versions ?? [],
columns.map (column => fields.find (field => field.key === column)!),
params, locale, rowNumbers ? (data.page - 1) * data.limit + 1 : null)}
identity={`${resource}:${countsRequestParams (params)}:${tableColumns.join (',')}:${
identity={`${resource}:${countsRequestParams (params)}:${tableColumns.join (' ')}:${
(data.versions ?? []).map (row => row.id)}`}
className="text-left text-sm">
<thead className="sticky top-0 z-20 bg-background text-foreground
@@ -126,6 +134,7 @@ const CountsResults = (
</th>
: <CountsSortHeader key={column} field={column}
label={message (fields.find (field => field.key === column)!.label)}
dragId={column}
sorts={sorts} change={sort} structured={fields.find (
field => field.key === column)?.children != null}/>)}
</tr></thead>
@@ -148,6 +157,7 @@ const CountsResults = (
{(data.versions?.length ?? 0) === 0
&& <p className="p-2">{label ('empty')}</p>}
</div>)
}}</SortableColumns>
}
+57 -20
ファイルの表示
@@ -16,8 +16,29 @@ type Selection = { identity: string; anchor: Point; end: Point }
const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
const cellGrid = (table: HTMLTableElement): HTMLTableCellElement[][] => {
const grid: HTMLTableCellElement[][] = []
for (const [row, tr] of Array.from (table.tBodies[0]?.rows ?? []).entries ())
{
grid[row] ??= []
let column = 0
for (const cell of Array.from (tr.cells))
{
while (grid[row][column] != null) ++column
for (let y = 0; y < cell.rowSpan; ++y)
{
grid[row + y] ??= []
for (let x = 0; x < cell.colSpan; ++x) grid[row + y][column + x] = cell
}
column += cell.colSpan
}
}
return grid
}
const cellAt = (table: HTMLTableElement, point: Point) =>
table.tBodies[0]?.rows[point.row]?.cells[point.column]
cellGrid (table)[point.row]?.[point.column]
const CountsSelectableTable = (
@@ -66,31 +87,48 @@ const CountsSelectableTable = (
}
}, [])
useLayoutEffect (() => {
const rows = root.current?.tBodies[0]?.rows
if (rows == null) return
for (const [row, tr] of Array.from (rows).entries ())
if (root.current == null) return
const grid = cellGrid (root.current)
const assigned = new Set<HTMLTableCellElement> ()
for (const [row, cells] of grid.entries ())
{
for (const [column, cell] of Array.from (tr.cells).entries ())
for (const [column, cell] of 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
if (assigned.has (cell)) continue
assigned.add (cell)
const selected = bounds != null && row <= bounds.bottom
&& row + cell.rowSpan - 1 >= bounds.top && column <= bounds.right
&& column + cell.colSpan - 1 >= bounds.left
cell.id = `${id}-${row}-${column}`
if (cell.getAttribute ('role') !== 'rowheader') cell.setAttribute ('role', 'gridcell')
cell.setAttribute ('aria-selected', String (selected))
if (selected) cell.dataset.countsSelected = ''
else delete cell.dataset.countsSelected
}
}
if (bounds != null)
root.current.setAttribute ('aria-activedescendant', grid[bounds.bottom]?.[bounds.right]?.id ?? '')
else root.current.removeAttribute ('aria-activedescendant')
}, [bounds, children, id])
const pointFor = (element: Element | null): Point | null => {
const pointFor = (element: Element | null, y?: number): 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 grid = cellGrid (table)
const hitRow = y == null ? -1 : Array.from (table.tBodies[0].rows).findIndex (row => {
const rect = row.getBoundingClientRect ()
return y >= rect.top && y < rect.bottom
})
for (const [row, cells] of grid.entries ())
{
if (hitRow >= 0 && row !== hitRow) continue
const column = cells.indexOf (cell as HTMLTableCellElement)
if (column >= 0) return { row, column }
}
return null
}
const closeMenu = useCallback (() => {
setMenu (null)
@@ -181,8 +219,7 @@ const CountsSelectableTable = (
<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',
@@ -213,7 +250,7 @@ const CountsSelectableTable = (
if (event.clientX >= rect.left + element.clientLeft + element.clientWidth
|| event.clientY >= rect.top + element.clientTop + element.clientHeight) return
}
const point = pointFor (element)
const point = pointFor (element, event.clientY)
if (point == null) return
event.stopPropagation ()
setMenu (null)
@@ -246,7 +283,7 @@ const CountsSelectableTable = (
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))
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY), event.clientY)
if (point == null) return
if (touchArmed.current && touchPress.current != null
&& Math.hypot (event.clientX - touchPress.current.x,
@@ -263,7 +300,7 @@ const CountsSelectableTable = (
onContextMenu={event => {
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
const point = pointFor (element)
const point = pointFor (element, event.clientY)
if (point == null || element.closest ('[data-counts-resize]') != null) return
event.preventDefault ()
event.stopPropagation ()
+13 -4
ファイルの表示
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import SortableItem from '@/components/common/SortableItem'
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
import { Button } from '@/components/ui/button'
import { useUITranslation } from '@/lib/useUITranslation'
@@ -8,13 +9,14 @@ import type { CountSort } from '@/lib/counts'
const CountsSortHeader = (
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true }: {
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true, dragId, totalKind }: {
field: string
label: { text: string; language: string }
sorts: CountSort[]
change: (key: string, additive: boolean) => void
structured?: boolean
rowSpan?: number; colSpan?: number; resizable?: boolean },
rowSpan?: number; colSpan?: number; resizable?: boolean
dragId?: string; totalKind?: string },
) => {
const { message } = useUITranslation ('counts')
const press = useRef<{ x: number; y: number
@@ -36,10 +38,14 @@ const CountsSortHeader = (
column: label.text, priority: priority + 1,
direction: message (`sort.${state}`).text })
return (
<th data-counts-heading={label.text} rowSpan={rowSpan} colSpan={colSpan}
const render = (shell?: Parameters<NonNullable<
Parameters<typeof SortableItem>[0]['renderShell']>>[0]) => (
<th ref={shell?.ref} style={shell?.style} data-counts-total={totalKind}
data-counts-heading={label.text} rowSpan={rowSpan} colSpan={colSpan}
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
<div className="flex min-w-0 items-center">
{shell?.handle}
<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}
@@ -90,8 +96,11 @@ const CountsSortHeader = (
{priority >= 0 && <span aria-hidden>
{direction === 'asc' ? '▲' : '▼'}{sorts.length > 1 && priority + 1}</span>}
</Button>
</div>
{resizable && <CountsResizeHandle field={field} label={label}/>}
</th>)
return dragId == null ? render () : <SortableItem id={dragId} name={message ('reorder', { column: label.text })}
handleId={`column-${dragId}`} moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
}
+100 -31
ファイルの表示
@@ -1,22 +1,27 @@
import SortableColumns from '@/components/common/SortableColumns'
import { countsSummaryValue } from '@/lib/countsDisplay'
import PrefetchLink from '@/components/PrefetchLink'
import CountsRowNumber from '@/components/counts/CountsRowNumber'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
import { countsAxisLabel, countsFieldLabel, readCountsJSON, useCountsDefinition }
import { countsAxisLabel, useCountsDefinition }
from '@/lib/countsDefinitions'
import { cn } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState } from '@/lib/queryState'
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
import type { CountsAxis } from '@/lib/countsDefinitions'
const CountsSummaryResults = (
{ resource, data, params, pending, update }: {
const CountsSummaryTable = (
{ resource, data, params, pending, update, dragIds = {} }: {
dragIds?: Record<string, string>
resource: CountsResource; data: CountsResult; params: URLSearchParams
pending: boolean; update: (key: string, value: string) => void },
pending: boolean; update: (key: string, value: string, extra?: Record<string, string>) => void },
) => {
const definition = useCountsDefinition ()
const locale = useLocale ()
@@ -33,30 +38,38 @@ const CountsSummaryResults = (
const sorts = readCountSort (params.get ('sort'))
const change = (key: string, additive: boolean) => update (
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
const display = (value: string | number | null, axis: CountsAxis, index: number): string => {
if (value == null) return axis.field === 'user' ? message ('bot').text : ''
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
if (axis.field === 'user') return message ('actorWithId', {
name: logical || message ('notRegistered').text, id: value }).text
if (logical != null) return logical
if (['tags', 'language_code', 'source_language_code'].includes (axis.field))
return message ('notRegistered').text
if (axis.field === 'changed_field') return message (countsFieldLabel (String (value))).text
if (axis.field === 'event_type') return message (`events.${value}`).text
if (axis.field === 'category' || axis.field === 'tag_category')
return message (`common:categories.${value}`).text
return String (value)
}
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
countsSummaryValue (value, axis, index, data, locale)
const aggregateLabel = (header: CountsPivotHeader) => message (
header.kind === 'grand_total' ? 'grandTotal' : 'subtotal').text
header.kind === 'grand_total' ? (rows.length + cols.length === 1
? 'singleTotal' : 'grandTotal') : 'subtotal').text
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
const levels = Math.max (1, cols.length)
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
header.values.map ((value, index) => display (value, axes[index], offset + index))
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
const measureKey = (header: CountsPivotHeader) => header.values.length === 0 ? 'count'
: `measure_${[...new TextEncoder ().encode (JSON.stringify (header.values))]
.map (byte => byte.toString (16).padStart (2, '0')).join ('')}`
const measures = readCountState<Record<string, string | number | null>[]> (
params, 'measures', [])
const measureKey = (header: CountsPivotHeader) => {
if (header.values.length === 0) return 'count'
const ids = [...new Set (measures.map (entry => String (entry.key)))]
const existing = ids.find (id => JSON.stringify (measures.filter (entry => entry.key === id)
.sort ((a, b) => Number (a.axis) - Number (b.axis)).map (entry => entry.value))
=== JSON.stringify (header.values))
return existing ?? `m${Array.from ({ length: 100 }, (_, index) => index)
.find (index => !ids.includes (`m${index}`)) ?? 99}`
}
const changeMeasure = (header: CountsPivotHeader, additive: boolean) => {
const key = measureKey (header)
const next = new URLSearchParams (params)
const nextSort = changeCountSort (sorts, key, additive)
const active = new Set (nextSort.map (sort => sort.key))
const records = measures.filter (record => record.key !== key && active.has (String (record.key)))
setQueryRecords (next, 'measures', [...records, ...(active.has (key)
? header.values.map ((value, axis) => ({ key, axis, value })) : [])])
update ('sort', writeCountSort (nextSort),
queryRecordParams (next, 'measures'))
}
const keys = [...(numbered ? ['row_number'] : []),
...Array.from ({ length: leading }, (_, index) => `a${index}`),
...columnHeaders.map (columnKey)]
@@ -69,8 +82,8 @@ const CountsSummaryResults = (
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
const next = new URLSearchParams (params)
const existing = readCountsJSON<unknown[]> (params, 'drill', [])
next.set ('drill', JSON.stringify ([...existing,
const existing = readCountState<Record<string, string | number | null>[]> (params, 'drill', [])
setCountState (next, 'drill', encodeCountState ('drill', [...existing,
...row.values.map ((value, index) => ({ ...rows[index], value })),
...column.values.map ((value, index) => ({ ...cols[index], value }))]))
next.set ('mode', 'details')
@@ -86,6 +99,25 @@ const CountsSummaryResults = (
return [...(numbered ? [number] : []),
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
})
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
if (row.kind !== 'data') return 1
const prefix = JSON.stringify (row.values.slice (0, axis + 1))
const previous = rowHeaders[index - 1]
if (previous?.kind === 'data'
&& JSON.stringify (previous.values.slice (0, axis + 1)) === prefix) return 0
let span = 1
while (index + span < rowHeaders.length)
{
const next = rowHeaders[index + span]
if (next.kind !== 'data'
|| JSON.stringify (next.values.slice (0, axis + 1)) !== prefix) break
++span
}
return span
}))
rowLabels.forEach ((labels, row) => spans[row].forEach ((span, column) => {
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
}))
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
const headingRows = Array.from ({ length: levels }, (_, level) => [
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
@@ -134,9 +166,13 @@ const CountsSummaryResults = (
const field = leaf ? columnKey (header) : sortKey
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
colSpan={span} rowSpan={aggregate ? levels - level : 1} resizable={leaf}
totalKind={header.kind !== 'data' ? header.kind : undefined}
dragId={level === 0 && header.values.length > 0
? dragIds[JSON.stringify (header.values.slice (0, 1))] : undefined}
label={{ text, language: message ('count').language }}
sorts={sorts.map (sort => sort.key === sortKey ? { ...sort, key: field } : sort)}
change={(_, additive) => change (sortKey, additive)}/>)
change={(_, additive) => leaf ? changeMeasure (header, additive)
: change (sortKey, additive)}/>)
index += span - 1
}
return output
@@ -161,14 +197,21 @@ const CountsSummaryResults = (
</tr>)}
</thead>
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
{rowLabels[index].map ((value, offset) => <td key={offset} data-counts-copy={value}
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
row.kind !== 'data' && 'bg-slate-200 text-slate-950 font-semibold',
row.kind !== 'data' && 'dark:bg-slate-700 dark:text-slate-50',
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>)}
{rowLabels[index].map ((value, offset) => {
const axis = offset - (numbered ? 1 : 0)
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
return span === 0 ? null : <td key={offset} rowSpan={span}
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>
})}
{columnHeaders.map (column => <td key={columnKey (column)}
data-counts-copy={String (count (row, column))}
data-counts-total={(row.kind === 'grand_total' || column.kind === 'grand_total')
? 'grand_total' : ((row.kind === 'subtotal' || column.kind === 'subtotal')
? 'subtotal' : undefined)}
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
? '' : undefined}
className={cn ('border px-2 py-1 dark:border-slate-700',
@@ -183,4 +226,30 @@ const CountsSummaryResults = (
</div>
}
const CountsSummaryResults = (props: Parameters<typeof CountsSummaryTable>[0]) => {
const locale = useLocale ()
const { message } = useUITranslation ('counts')
const headers = props.data.columnHeaders ?? []
const columns = props.data.cols ?? []
if (columns.length === 0) return <CountsSummaryTable {...props}/>
const groups = [...new Set (headers.filter (header => header.values.length > 0)
.map (header => JSON.stringify (header.values.slice (0, 1))))]
const ids = groups.map ((_, index) => `group${index}`)
const dragIds = Object.fromEntries (groups.map ((group, index) => [group, ids[index]]))
return <SortableColumns ids={ids} name={id => ({
text: countsSummaryValue (JSON.parse (groups[ids.indexOf (id)])[0], columns[0],
props.data.rows?.length ?? 0, props.data, locale), language: message ('count').language })}
onChange={order => props.update ('column_order', encodeCountState ('column_order',
order.map (id => ({ value: JSON.parse (groups[ids.indexOf (id)])[0] }))))}>
{order => {
const position = (header: CountsPivotHeader) => header.values.length === 0 ? order.length
: order.indexOf (dragIds[JSON.stringify (header.values.slice (0, 1))])
const sorted = [...headers].sort ((a, b) => position (a) - position (b))
return <CountsSummaryTable {...props} data={{ ...props.data, columnHeaders: sorted }}
dragIds={dragIds}/>
}}
</SortableColumns>
}
export default CountsSummaryResults
+23
ファイルの表示
@@ -54,3 +54,26 @@
.counts-nested {
@apply border-l-2 border-slate-300 dark:border-slate-600;
}
.counts-grid > tbody > tr > td[data-counts-total='subtotal']:not([data-counts-selected]),
.counts-grid > thead > tr > th[data-counts-total='subtotal'] {
background-color: #e2e8f0;
color: #0f172a;
font-weight: 600;
}
.counts-grid > tbody > tr > td[data-counts-total='grand_total']:not([data-counts-selected]),
.counts-grid > thead > tr > th[data-counts-total='grand_total'] {
background-color: #cbd5e1;
color: #0f172a;
font-weight: 700;
}
.dark .counts-grid > tbody > tr > td[data-counts-total='subtotal']:not([data-counts-selected]),
.dark .counts-grid > thead > tr > th[data-counts-total='subtotal'] {
background-color: #334155;
color: #f1f5f9;
}
.dark .counts-grid > tbody > tr > td[data-counts-total='grand_total']:not([data-counts-selected]),
.dark .counts-grid > thead > tr > th[data-counts-total='grand_total'] {
background-color: #475569;
color: #fff;
}
+54 -24
ファイルの表示
@@ -1,6 +1,8 @@
import { countsFieldLabel, readCountsJSON } from '@/lib/countsDefinitions'
import { countsFieldLabel } from '@/lib/countsDefinitions'
import { apiDownload, apiGet } from '@/lib/api'
import { readCountState, encodeCountState } from '@/lib/queryState'
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types'
@@ -58,6 +60,8 @@ export type CountsStateTotals = {
export type CountsPivotHeader = { values: (string | number | null)[]
kind: 'data' | 'subtotal' | 'grand_total' }
export type CountsResult = {
populationCount?: number
populationCountScope?: string
rowHeaders?: CountsPivotHeader[]
columnHeaders?: CountsPivotHeader[]
pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[]
@@ -95,7 +99,7 @@ export const countColumns: Record<string, string> = {
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
original_created_from: 'counts:columns.originalFrom',
original_created_before: 'counts:columns.originalBefore',
name: 'history:tagName', names: 'counts:columns.names',
name: 'counts:columns.name', names: 'counts:columns.names',
category: 'common:categoryLabel', aliases: 'tags:aliases',
deprecated: 'counts:columns.deprecated', user: 'history:actor',
created_at: 'counts:columns.changedAt' }
@@ -165,7 +169,9 @@ export const fieldsForCounts = (
key,
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
: (countColumns[key] ?? countsFieldLabel (key)),
children: nested[key] }))
children: key === 'parents' && resource === 'tags'
? [...(nested.parents ?? []), { key: 'name', label: 'counts:columns.name' }]
: nested[key] }))
export const orderedCountFields = (
@@ -174,7 +180,7 @@ export const orderedCountFields = (
parent = '',
): CountField[] => {
const prefix = parent ? `${parent}.` : ''
const saved = (params.get ('order') ?? '').split (',')
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)
@@ -201,8 +207,8 @@ export const reorderCountFields = (
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 (',')
return [...(params.get ('order') ?? '').split (' ').filter (
key => key && !current.includes (key)), ...next].join (' ')
}
@@ -220,7 +226,7 @@ export const selectedCountColumns = (
tag_descriptions: ['language_code', 'body', 'source_language_code'],
tag_groups: ['name', 'description', 'members'],
materials: ['url', 'source_kind', 'tag_id'] }
const selected = resolved.has ('columns') ? resolved.get ('columns')!.split (',')
const selected = resolved.has ('columns') ? resolved.get ('columns')!.split (' ')
: [...common, ...defaults[resource]]
return orderedCountFields (fieldsForCounts (resource, definition), resolved)
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
@@ -234,13 +240,26 @@ export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
})
export const selectedCountChildren = (params: URLSearchParams): Set<string> =>
new Set (params.has ('children') ? params.get ('children')!.split (',')
: ['tags.name', 'parents.id', 'aliases.name', 'members.tagId'])
export const selectedCountChildren = (params: URLSearchParams): Set<string> => {
const selected = new Set (params.has ('children') ? params.get ('children')!.split (' ')
: ['tags.name', 'parents.id', 'aliases.name', 'members.tagId'])
const clean = (fields: CountField[], prefix: string) => {
for (const field of fields)
{
const path = `${prefix}.${field.key}`
if (field.children == null) continue
clean (field.children, path)
if (!field.children.some (child => selected.has (`${path}.${child.key}`)))
selected.delete (path)
}
}
for (const [path, fields] of Object.entries (nested)) clean (fields ?? [], path)
return selected
}
export const readCountSort = (value: string | null): CountSort[] =>
(value ?? '').split (',').flatMap (entry => {
(value ?? '').split (' ').flatMap (entry => {
const [key, direction] = entry.split (':')
return (key && (direction === 'asc' || direction === 'desc'))
? [{ key, direction }] : []
@@ -248,7 +267,7 @@ export const readCountSort = (value: string | null): CountSort[] =>
export const writeCountSort = (sort: CountSort[]): string =>
sort.map (item => `${item.key}:${item.direction}`).join (',')
sort.map (item => `${item.key}:${item.direction}`).join (' ')
export const changeCountSort = (
@@ -274,7 +293,7 @@ export const selectedCountFields = (
parent = '',
): CountField[] => {
const selected = params.has (selectionKey)
? new Set (params.get (selectionKey)!.split (',')) : null
? new Set (params.get (selectionKey)!.split (' ')) : null
return orderedCountFields (fields, params, parent)
.filter (field => selected == null || selected.has (field.key))
}
@@ -300,7 +319,7 @@ export const normaliseCountsParams = (
if (!params.has ('rows') && params.has ('axis'))
{
const field = params.get ('axis') === 'month' ? 'created_at' : params.get ('axis')!
params.set ('rows', JSON.stringify ([{
params.set ('rows', encodeCountState ('rows', [{
field, ...(field === 'created_at' ? { grain: 'month' } : { }) }]))
}
if (params.has ('before') && !params.has ('through'))
@@ -309,16 +328,16 @@ export const normaliseCountsParams = (
params.delete (key)
if (params.has ('columns') && ['posts', 'tags'].includes (resource))
{
const selected = new Set (params.get ('columns')!.split (','))
const selected = new Set (params.get ('columns')!.split (' '))
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
.join (','))
.join (' '))
}
if (params.has ('sort'))
params.set ('sort', params.get ('sort')!.split (',')
.filter (entry => entry.split (':')[0] !== 'original_created_at').join (','))
params.set ('sort', params.get ('sort')!.split (' ')
.filter (entry => entry.split (':')[0] !== 'original_created_at').join (' '))
if (params.has ('order'))
params.set ('order', params.get ('order')!.split (',')
.filter (key => key !== 'original_created_at').join (','))
params.set ('order', params.get ('order')!.split (' ')
.filter (key => key !== 'original_created_at').join (' '))
const defaults = definition?.defaults?.find (entry =>
entry.definition === params.get ('definition') && entry.mode === params.get ('mode'))
for (const { key, value } of defaults?.params ?? [])
@@ -327,13 +346,24 @@ export const normaliseCountsParams = (
if (key === 'sort')
{
const expected = defaults?.params.find (entry => entry.key === 'rows')?.value
const axes = readCountsJSON<CountsAxis[]> (params, 'rows', [])
const first = expected == null ? null : (JSON.parse (expected) as CountsAxis[])[0]
const axes = readCountState<CountsAxis[]> (params, 'rows', [])
const first = expected == null ? null : readCountState<CountsAxis[]> (new URLSearchParams ({ rows: expected }), 'rows', [])[0]
if (!Array.isArray (axes) || axes[0]?.field !== first?.field
|| (axes[0]?.grain ?? 'month') !== (first?.grain ?? 'month')) continue
}
params.set (key, value)
}
const children = selectedCountChildren (params)
if (params.has ('children')) params.set ('children', [...children].filter (Boolean).join (' '))
if (params.has ('columns'))
{
const fields = fieldsForCounts (resource, definition)
params.set ('columns', params.get ('columns')!.split (' ').filter (key => {
const field = fields.find (field => field.key === key)
return field != null && (field.children == null
|| field.children.some (child => children.has (`${key}.${child.key}`)))
}).join (' '))
}
return params
}
@@ -393,13 +423,13 @@ export const countsExportPayload = (
{
const selected = selectedCountChildren (params)
payload.set ('children', countChildPaths (fieldsForCounts (resource, definition))
.filter (path => selected.has (path)).join (','))
.filter (path => selected.has (path)).join (' '))
}
payload.delete ('current')
payload.delete ('row_numbers')
if (isCountsState (params) && countsUseLatest (params)) payload.delete ('at')
return { ...Object.fromEntries (payload), resource, format,
columns: columns.join (','), headers: JSON.stringify (headers),
columns: columns.join (' '), headers: JSON.stringify (headers),
labels: JSON.stringify (labels) }
}
-12
ファイルの表示
@@ -75,15 +75,3 @@ export const countsAxisFields = (
history: boolean,
): CountField[] => definition.axes[history ? 'history' : 'latest']
.map (key => ({ key, label: countsFieldLabel (key) }))
export const readCountsJSON = <T,> (params: URLSearchParams, key: string, fallback: T): T => {
try
{
return JSON.parse (params.get (key) ?? JSON.stringify (fallback)) as T
}
catch
{
return fallback
}
}
+28 -3
ファイルの表示
@@ -1,14 +1,14 @@
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
sortedCountChildren } from '@/lib/counts'
import { countsEventTypes } from '@/lib/countsDefinitions'
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
import { translateUIMessage } from '@/lib/i18n'
import { localisedDateString, msToTime } from '@/lib/utils'
import type { CountColumn, CountField, CountRecord,
import type { CountColumn, CountField, CountRecord, CountsResult,
CountsResource, CountsVersion } from '@/lib/counts'
import type { CountsClipboardCell, CountsClipboardColumn, CountsClipboardTable }
from '@/lib/countsClipboard'
import type { CountsDefinition } from '@/lib/countsDefinitions'
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
@@ -308,6 +308,7 @@ export const countsExportLabels = (
for (const event of countsEventTypes)
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
labels.subtotal = translateUIMessage ('subtotal', 'counts', locale).text
labels.singleTotal = translateUIMessage ('singleTotal', 'counts', locale).text
labels.grandTotal = translateUIMessage ('grandTotal', 'counts', locale).text
labels.bot = translateUIMessage ('bot', 'counts', locale).text
labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, {
@@ -384,3 +385,27 @@ export const countsDetailClipboardTable = (
return rowStart == null ? cells : [String (rowStart + index), ...cells]
}) }
}
export const countsSummaryValue = (
value: string | number | null,
axis: CountsAxis,
index: number,
data: CountsResult,
locale: LocaleCode,
): string => {
const message = (key: string, values?: Record<string, string | number>) =>
translateUIMessage (key, 'counts', locale, values).text
if (value == null) return axis.field === 'user' ? message ('bot') : ''
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
if (axis.field === 'user') return message ('actorWithId', {
name: logical || message ('notRegistered'), id: value })
if (logical != null) return logical
if (['tags', 'language_code', 'source_language_code'].includes (axis.field))
return message ('notRegistered')
if (axis.field === 'changed_field') return message (countsFieldLabel (String (value)))
if (axis.field === 'event_type') return message (`events.${value}`)
if (axis.field === 'category' || axis.field === 'tag_category')
return message (`common:categories.${value}`)
return String (value)
}
+5 -4
ファイルの表示
@@ -1,4 +1,5 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import { queryRecordParams, setQueryRecords } from '@/lib/queryState'
import { switchLocalePath } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
@@ -17,7 +18,7 @@ export const fetchPosts = async (
...(url && { url }),
...(title && { title }),
...(tags && { tags }),
...(tagReferences && { tag_refs: tagReferences }),
...(tagReferences && queryRecordParams (new URLSearchParams (tagReferences), 'tag_refs')),
...(match && { match }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
@@ -45,14 +46,14 @@ export const switchPostFilterLocale = async (
negative?: boolean
missing?: boolean }[] }> ('/posts/tag_filters', {
locale, params: { tags, target_locale: targetLocale,
tag_refs: query.get ('tag_refs') ?? undefined } })
...queryRecordParams (query, 'tag_refs') } })
query.set ('tags', references.map (reference => reference.name).join (' '))
query.set ('tag_refs', JSON.stringify (references.map (reference => ({
setQueryRecords (query, 'tag_refs', references.map (reference => ({
name: reference.name,
negative: reference.negative,
...(reference.tagId != null && { tag_id: reference.tagId }),
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
...(reference.missing && { missing: true }) }))))
...(reference.missing && { missing: true }) })))
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
}
+3 -2
ファイルの表示
@@ -1,3 +1,4 @@
import { queryRecordParams } from '@/lib/queryState'
import { QueryClient } from '@tanstack/react-query'
import { match } from 'path-to-regexp'
@@ -175,8 +176,8 @@ const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
const keys = {
tags, match: m, page, limit, url: qURL, title,
...(url.searchParams.get ('tag_refs') && {
tagReferences: url.searchParams.get ('tag_refs')! }),
...(url.searchParams.has ('tag_refs') && {
tagReferences: new URLSearchParams (queryRecordParams (url.searchParams, 'tag_refs')).toString () }),
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order }
+105
ファイルの表示
@@ -0,0 +1,105 @@
// Public URL grammar: space-delimited tokens and indexed scalar fields, never JSON.
export type QueryRecord = Record<string, string | number | boolean | null | undefined>
const schemas: Record<string, string[]> = {
conditions: ['field', 'operator', 'value', 'end', 'blank'],
drill: ['field', 'grain', 'value', 'value_type'],
tag_refs: ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
column_order: ['value', 'value_type'],
measures: ['key', 'axis', 'value', 'value_type'] }
export const queryList = (value: string | null): string[] =>
value == null || value === '' ? [] : value.split (' ')
export const queryRecords = (params: URLSearchParams, key: string): QueryRecord[] => {
const indexed = new Map<number, QueryRecord> ()
for (const [name, value] of params)
{
if (!name.startsWith (`${key}.`)) continue
const parts = name.slice (key.length + 1).split ('.')
if (parts.length !== 2 || !/^\d{1,2}$/.test (parts[0])
|| !schemas[key]?.includes (parts[1])) continue
const index = Number (parts[0])
const record = indexed.get (index) ?? { }
const field = parts[1]
record[field] = ['blank', 'negative', 'missing'].includes (field)
? value === '1' : (['tag_id', 'external_tag_id', 'axis'].includes (field)
? Number (value) : value)
indexed.set (index, record)
}
return [...indexed].sort ((a, b) => a[0] - b[0]).map (([, record]) => {
if (record.value_type === 'null') record.value = null
if (record.value_type === 'number') record.value = Number (record.value)
if (record.value_type === 'boolean') record.value = record.value === 'true'
delete record.value_type
return record
})
}
export const setQueryRecords = (
params: URLSearchParams,
key: string,
records: QueryRecord[],
): void => {
for (const name of [...params.keys ()])
if (name === key || name.startsWith (`${key}.`)) params.delete (name)
params.set (key, '')
records.forEach ((record, index) => {
for (const [field, value] of Object.entries (record))
{
if (value == null && field !== 'value') continue
if (!schemas[key]?.includes (field)) continue
const name = `${key}.${index}.${field}`
if (field === 'value' && ['drill', 'measures', 'column_order'].includes (key))
params.set (`${key}.${index}.value_type`, value == null ? 'null' : typeof value)
if (value == null) continue
params.set (name, typeof value === 'boolean'
? (['blank', 'negative', 'missing'].includes (field) ? (value ? '1' : '0') : String (value))
: String (value))
}
})
}
export const readCountState = <T,> (
params: URLSearchParams,
key: string,
fallback: T,
): T => {
if (!params.has (key)) return fallback
if (key === 'rows' || key === 'cols')
return queryList (params.get (key)).map (token => {
const [field, grain] = token.split (':')
return grain == null ? { field } : { field, grain }
}) as T
return queryRecords (params, key) as T
}
export const encodeCountState = (key: string, records: QueryRecord[]): string => {
if (key === 'rows' || key === 'cols')
return records.map (record => record.grain == null
? String (record.field) : `${record.field}:${record.grain}`).join (' ')
const params = new URLSearchParams ()
setQueryRecords (params, key, records)
return params.toString ()
}
export const setCountState = (params: URLSearchParams, key: string, value: string): void => {
if (schemas[key] != null)
{
const fields = new URLSearchParams (value)
setQueryRecords (params, key, queryRecords (fields, key))
}
else params.set (key, value)
}
export const queryRecordParams = (
params: URLSearchParams,
key: string,
): Record<string, string> => Object.fromEntries ([...params].filter (([name]) =>
name === key || name.startsWith (`${key}.`)))
+3 -1
ファイルの表示
@@ -43,6 +43,7 @@
"columns": {
"changedAt": "操作日時",
"version": "版",
"name": "名称",
"resourceId": "Id.",
"postTitle": "タイトル",
"postURL": "URL",
@@ -167,7 +168,7 @@
"changedField": "変更項目",
"eventType": "異動区分",
"count": "件数",
"recordTotal": "総件数:{{count}}",
"recordTotal": "閲覧可能件数:{{count}}",
"grain": "粒度",
"grains": {
"year": "年",
@@ -235,6 +236,7 @@
},
"rowNumberHeading": "No.",
"subtotal": "小計",
"singleTotal": "合計",
"grandTotal": "綜合計",
"rowNumbers": "行番号",
"periodSeparator": "〜",
+21 -8
ファイルの表示
@@ -20,7 +20,7 @@ import { countsRequestParams, defaultCountsMode, countsExportPayload, fieldsForC
import { countsExportLabels } from '@/lib/countsDisplay'
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
countsResources, countsFieldLabel,
fetchCountsDefinition, readCountsJSON, useCountsDefinition }
fetchCountsDefinition, useCountsDefinition }
from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
@@ -29,6 +29,8 @@ import useDialogue from '@/lib/dialogues/useDialogue'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { setCountState, readCountState } from '@/lib/queryState'
import type { CountsAxis } from '@/lib/countsDefinitions'
import type { CountsResource } from '@/lib/counts'
@@ -44,7 +46,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
controlParams.delete ('order')
controlParams.delete ('row_numbers')
controlParams.set ('columns', selectedCountColumns (resource, params, definition)
.filter (column => column !== 'thumbnail').sort ().join (','))
.filter (column => column !== 'thumbnail').sort ().join (' '))
const request = countsRequestParams (params)
const [open, setOpen] = useState (false)
const [sidebarOpen, setSidebarOpen] = useState (false)
@@ -99,10 +101,17 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
}, [canonical, data, isPlaceholderData, params, setParams])
const update = (key: string, value: string) => {
const update = (key: string, value: string, extra?: Record<string, string>) => {
const next = new URLSearchParams (params)
if (value || key === 'sort' || key === 'columns') next.set (key, value)
if (value || ['sort', 'columns', 'children', 'conditions', 'drill'].includes (key))
setCountState (next, key, value)
else next.delete (key)
if (extra != null)
{
for (const key of [...next.keys ()])
if (key === 'measures' || key.startsWith ('measures.')) next.delete (key)
for (const [key, value] of Object.entries (extra)) next.set (key, value)
}
if (key === 'sort') next.delete ('page')
setParams (next)
}
@@ -113,8 +122,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
setExportError (false)
try
{
const axes = [...readCountsJSON<CountsAxis[]> (params, 'rows', []),
...readCountsJSON<CountsAxis[]> (params, 'cols', [])]
const axes = [...readCountState<CountsAxis[]> (params, 'rows', []),
...readCountState<CountsAxis[]> (params, 'cols', [])]
const fields = mode === 'summary' ? [
...axes.map (axis => ({
key: axis.field, label: countsFieldLabel (axis.field) })),
@@ -212,7 +221,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
displayThumbnail={enabled => {
const columns = selectedCountColumns (resource, params, definition)
.filter (column => column !== 'thumbnail')
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (','))
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (' '))
}}
execute={next => {
if (next.toString () === canonical) void refetch ()
@@ -231,7 +240,11 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{data != null && <>
<p className="my-1 text-xs">{label ('recordTotal', { count: data.count })}</p>
<p className="my-1 flex flex-wrap gap-x-3 text-xs">
<span>{label ('recordTotal', { count: data.count })}</span>
{data.populationCount != null && <span>{label ('total', {
count: data.populationCount })}</span>}
</p>
{mode === 'summary' ? <CountsSummaryResults resource={resource}
data={data} params={params}
pending={isPlaceholderData} update={update}/> : (
+2 -1
ファイルの表示
@@ -1,3 +1,4 @@
import { queryRecordParams } from '@/lib/queryState'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
@@ -61,7 +62,7 @@ const PostListPage: FC = () => {
const keys = {
tags: tagsKey, match, page, limit,
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
...(query.has ('tag_refs') && { tagReferences: new URLSearchParams (queryRecordParams (query, 'tag_refs')).toString () }),
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order } as const
+2 -1
ファイルの表示
@@ -1,3 +1,4 @@
import { queryRecordParams } from '@/lib/queryState'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useState } from 'react'
@@ -126,7 +127,7 @@ const PostSearchPage: FC = () => {
const keys: FetchPostsParams = {
tags: qTags, match: qMatch, page, limit,
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
...(query.has ('tag_refs') && { tagReferences: new URLSearchParams (queryRecordParams (query, 'tag_refs')).toString () }),
url: qURL,
title: qTitle,
originalCreatedFrom: qOriginalCreatedFrom,