このコミットが含まれているのは:
@@ -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.
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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 = { }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class TagDescriptionVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
end
|
||||
|
||||
@@ -6,6 +6,8 @@ module VersionRecord
|
||||
discard: 'discard',
|
||||
restore: 'restore' }.freeze
|
||||
|
||||
def require_history_access! = HistoryAccess.require!(self)
|
||||
|
||||
def readonly? = persisted?
|
||||
|
||||
included do
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ module WikiPageRepr
|
||||
module_function
|
||||
|
||||
def tag_description locale, description
|
||||
HistoryAccess.require_current!(description, TagDescriptionVersion)
|
||||
tag = description.tag
|
||||
|
||||
{ 'id' => description.id,
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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] || '' }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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}.`)))
|
||||
@@ -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": "〜",
|
||||
|
||||
@@ -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}/> : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする