このコミットが含まれているのは:
@@ -1,5 +1,18 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Mandatory security and URL contracts
|
||||||
|
|
||||||
|
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
|
||||||
|
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
|
||||||
|
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
|
||||||
|
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
|
||||||
|
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
|
||||||
|
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
|
||||||
|
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
||||||
|
サーバーが確定した current_user の role のみを認可に使用する。
|
||||||
|
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
||||||
|
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
|
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# backend/AGENTS.md
|
# 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
|
## Scope
|
||||||
|
|
||||||
These rules apply to work under `backend/`.
|
These rules apply to work under `backend/`.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
class ApplicationController < ActionController::API
|
class ApplicationController < ActionController::API
|
||||||
|
include RequestAccess
|
||||||
include ActionController::Cookies
|
include ActionController::Cookies
|
||||||
include ActionController::RequestForgeryProtection
|
include ActionController::RequestForgeryProtection
|
||||||
|
|
||||||
@@ -8,34 +9,46 @@ class ApplicationController < ActionController::API
|
|||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||||
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
|
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
|
||||||
|
rescue_from(ActiveRecord::RecordNotFound) { head :not_found }
|
||||||
rescue_from(UnsupportedLocale) { head :not_found }
|
rescue_from(UnsupportedLocale) { head :not_found }
|
||||||
|
|
||||||
before_action :reject_banned_ip_address!
|
before_action :reject_banned_ip_address!
|
||||||
before_action :authenticate_user
|
before_action :authenticate_user
|
||||||
before_action :reject_banned_user!
|
before_action :reject_banned_user!
|
||||||
before_action :validate_explicit_locale!
|
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
|
protect_from_forgery with: :exception
|
||||||
self.forgery_protection_origin_check = true
|
self.forgery_protection_origin_check = true
|
||||||
rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf
|
rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf
|
||||||
after_action :prevent_authenticated_caching
|
after_action :prevent_authenticated_caching
|
||||||
|
|
||||||
def current_user = @current_user
|
|
||||||
|
|
||||||
private
|
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
|
def reject_invalid_csrf
|
||||||
response.headers['X-CSRF-Invalid'] = '1'
|
response.headers['X-CSRF-Invalid'] = '1'
|
||||||
response.headers['Cache-Control'] = 'private, no-store'
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
head :forbidden
|
head :forbidden
|
||||||
end
|
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
|
def prevent_authenticated_caching
|
||||||
response.headers['Cache-Control'] = 'private, no-store' if current_user
|
response.headers['Cache-Control'] = 'private, no-store' if current_user
|
||||||
end
|
end
|
||||||
@@ -79,19 +92,6 @@ class ApplicationController < ActionController::API
|
|||||||
render_validation_error base: ['すでに存在してゐます.']
|
render_validation_error base: ['すでに存在してゐます.']
|
||||||
end
|
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
|
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
|
||||||
errors = { }
|
errors = { }
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ module CountsListing
|
|||||||
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
|
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
|
||||||
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
|
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
|
||||||
.merge('locale' => locale.code))
|
.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) }
|
at: request.query_parameters['at'].presence || Time.current.utc.iso8601(6) }
|
||||||
if query.mode == 'summary'
|
if query.mode == 'summary'
|
||||||
result.merge!(query.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)
|
.merge(request.query_parameters)
|
||||||
exporter = CountsExporter.new(payload,
|
exporter = CountsExporter.new(payload,
|
||||||
language_code: locale.language_code)
|
language_code: locale.language_code)
|
||||||
response.headers['Content-Type'] = exporter.format == 'json' ?
|
session[:counts_export_owner] ||= SecureRandom.hex(32)
|
||||||
'application/json; charset=utf-8' : 'text/csv; charset=utf-8'
|
token = CountsExportDownload.prepare(exporter, session[:counts_export_owner])
|
||||||
response.headers['Content-Disposition'] =
|
file = CountsExportDownload.find(token, session[:counts_export_owner])
|
||||||
"attachment; filename=\"counts-#{ exporter.resource }.#{ exporter.format }\""
|
return head :not_found unless file
|
||||||
response.headers['Cache-Control'] = 'private, no-store'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
send_file file.fetch('path'),
|
||||||
# Rack::ETag otherwise materialises the response to calculate a digest.
|
filename: "counts-#{ exporter.resource }.#{ exporter.format }",
|
||||||
response.headers['Last-Modified'] = Time.current.httpdate
|
type: exporter.format == 'json' ? 'application/json' : 'text/csv',
|
||||||
self.response_body = exporter.body
|
disposition: 'attachment'
|
||||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||||
head :bad_request
|
head :bad_request
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class MaterialVersionsController < ApplicationController
|
|||||||
|
|
||||||
offset = (page - 1) * limit
|
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(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(tag_name: params[:tag].to_s.strip) if params[:tag].present?
|
||||||
q = q.where(event_type: params[:event_type]) if params[:event_type].present?
|
q = q.where(event_type: params[:event_type]) if params[:event_type].present?
|
||||||
@@ -20,7 +20,7 @@ class MaterialVersionsController < ApplicationController
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
|
|
||||||
render json: { versions: serialise_versions(versions), count: }
|
render json: { versions: serialise_versions(versions), count:, population_count: HistoryAccess.population_count(MaterialVersion) }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class PostVersionsController < ApplicationController
|
|||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
q = version_scope
|
q = HistoryAccess.visible(version_scope)
|
||||||
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
||||||
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
|
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
|
||||||
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
|
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
|
||||||
@@ -32,7 +32,7 @@ class PostVersionsController < ApplicationController
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -101,7 +101,7 @@ class PostVersionsController < ApplicationController
|
|||||||
}
|
}
|
||||||
missing_ids = ids - names.keys
|
missing_ids = ids - names.keys
|
||||||
|
|
||||||
NicoTagVersion
|
HistoryAccess.visible(NicoTagVersion.all)
|
||||||
.where(tag_id: missing_ids)
|
.where(tag_id: missing_ids)
|
||||||
.order(:tag_id, version_no: :desc)
|
.order(:tag_id, version_no: :desc)
|
||||||
.pluck(:tag_id, :name)
|
.pluck(:tag_id, :name)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class PostsController < ApplicationController
|
|||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
pt_max_sql =
|
pt_max_sql =
|
||||||
PostVersion
|
HistoryAccess.visible(PostVersion.all)
|
||||||
.select('post_id, MAX(created_at) AS max_updated_at')
|
.select('post_id, MAX(created_at) AS max_updated_at')
|
||||||
.group('post_id')
|
.group('post_id')
|
||||||
.to_sql
|
.to_sql
|
||||||
@@ -344,6 +344,7 @@ class PostsController < ApplicationController
|
|||||||
unless force
|
unless force
|
||||||
base_version = post.post_versions.find_by!(version_no: base_version_no)
|
base_version = post.post_versions.find_by!(version_no: base_version_no)
|
||||||
|
|
||||||
|
HistoryAccess.require!(base_version)
|
||||||
base_snapshot = post_snapshot_from_version(base_version)
|
base_snapshot = post_snapshot_from_version(base_version)
|
||||||
current_snapshot = post_snapshot_from_record(post, locale)
|
current_snapshot = post_snapshot_from_record(post, locale)
|
||||||
end
|
end
|
||||||
@@ -449,7 +450,7 @@ class PostsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def tag_filter_references literals
|
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
|
rescue ArgumentError, JSON::ParserError, TypeError
|
||||||
raise ActionController::BadRequest
|
raise ActionController::BadRequest
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class SitemapEntriesController < ApplicationController
|
|||||||
when 'descriptions'
|
when 'descriptions'
|
||||||
TagDescription.where(language_code: locale.language_code, tag_id: named_tags)
|
TagDescription.where(language_code: locale.language_code, tag_id: named_tags)
|
||||||
.where.not(body: [nil, ''])
|
.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.tag_id = tag_descriptions.tag_id')
|
||||||
.where('tag_description_versions.language_code = tag_descriptions.language_code')
|
.where('tag_description_versions.language_code = tag_descriptions.language_code')
|
||||||
.where('tag_description_versions.version_no = tag_descriptions.version_no')
|
.where('tag_description_versions.version_no = tag_descriptions.version_no')
|
||||||
@@ -50,6 +50,7 @@ class SitemapEntriesController < ApplicationController
|
|||||||
when 'tag_groups'
|
when 'tag_groups'
|
||||||
TagGroup.joins(:versions)
|
TagGroup.joins(:versions)
|
||||||
.where('tag_group_versions.version_no = tag_groups.version_no')
|
.where('tag_group_versions.version_no = tag_groups.version_no')
|
||||||
|
.where(Arel.sql(HistoryAccess.sql(TagGroupVersion)))
|
||||||
when 'theatres'
|
when 'theatres'
|
||||||
Theatre.kept
|
Theatre.kept
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class TagDescriptionsController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def representation description
|
def representation description
|
||||||
|
HistoryAccess.require_current!(description, TagDescriptionVersion)
|
||||||
description.as_json(only: [
|
description.as_json(only: [
|
||||||
:tag_id,
|
:tag_id,
|
||||||
:language_code,
|
:language_code,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class TagGroupsController < ApplicationController
|
class TagGroupsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
locale = resolve_locale!
|
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)
|
render json: TagGroupRepr.many(locale, groups.to_a)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class TagVersionsController < ApplicationController
|
|||||||
|
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
|
|
||||||
q = version_scope
|
q = HistoryAccess.visible(version_scope)
|
||||||
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
||||||
|
|
||||||
count = q.except(:select, :order, :limit, :offset).count
|
count = q.except(:select, :order, :limit, :offset).count
|
||||||
@@ -19,7 +19,7 @@ class TagVersionsController < ApplicationController
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -31,6 +31,7 @@ class TagVersionsController < ApplicationController
|
|||||||
ON
|
ON
|
||||||
prev.tag_id = tag_versions.tag_id
|
prev.tag_id = tag_versions.tag_id
|
||||||
AND prev.version_no = tag_versions.version_no - 1
|
AND prev.version_no = tag_versions.version_no - 1
|
||||||
|
AND #{ HistoryAccess.sql(TagVersion, 'prev') }
|
||||||
SQL
|
SQL
|
||||||
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
||||||
'prev.deprecated_at AS prev_deprecated_at',
|
'prev.deprecated_at AS prev_deprecated_at',
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
descriptions =
|
descriptions =
|
||||||
TagDescription
|
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
|
||||||
.where(language_code: locale.language_code)
|
.where(language_code: locale.language_code)
|
||||||
.includes(tag: :tag_names)
|
.includes(tag: :tag_names)
|
||||||
|
|
||||||
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
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?
|
if title.present?
|
||||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||||
descriptions =
|
descriptions =
|
||||||
@@ -41,13 +41,13 @@ class WikiPagesController < ApplicationController
|
|||||||
return head :not_found unless language.is_a?(String)
|
return head :not_found unless language.is_a?(String)
|
||||||
return head :not_found unless Language.exists?(code: language)
|
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 })
|
.where(language_code: locale.language_code, tag_names: { language_code: language })
|
||||||
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
|
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
|
||||||
candidates = names.map do |tag_id, description_id, name|
|
candidates = names.map do |tag_id, description_id, name|
|
||||||
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
|
||||||
end
|
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:)
|
WikiPageRepr.autolink_group(locale, id:, name:, language:)
|
||||||
end)
|
end)
|
||||||
render json: candidates
|
render json: candidates
|
||||||
@@ -286,12 +286,12 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
tag_group = id && TagGroup.find_by(id:)
|
tag_group = id && TagGroup.find_by(id:)
|
||||||
if tag_group
|
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
|
end
|
||||||
|
|
||||||
json = []
|
json = []
|
||||||
unless id
|
unless id
|
||||||
json.concat(tag_group_changes(TagGroupVersion.all))
|
json.concat(tag_group_changes(HistoryAccess.visible(TagGroupVersion.all)))
|
||||||
json.concat(description_changes(locale,
|
json.concat(description_changes(locale,
|
||||||
TagDescription.where(language_code: locale.language_code)))
|
TagDescription.where(language_code: locale.language_code)))
|
||||||
json = json.each_with_index.sort_by do |row, index|
|
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
|
def render_group_representation representation, version, body: nil, status: :ok
|
||||||
body ||= TagGroupMarkdownRenderer.render(representation)
|
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
|
pred = versions.find_by(version_no: version.version_no - 1)&.id
|
||||||
succ = 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
|
updated_at = version.created_at
|
||||||
@@ -346,11 +346,11 @@ class WikiPagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def current_group_version group
|
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
|
end
|
||||||
|
|
||||||
def group_version group, version_id
|
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
|
end
|
||||||
|
|
||||||
def wiki_diff from_body, to_body
|
def wiki_diff from_body, to_body
|
||||||
@@ -373,7 +373,7 @@ class WikiPagesController < ApplicationController
|
|||||||
def tag_group_changes versions
|
def tag_group_changes versions
|
||||||
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
|
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)
|
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 })
|
version_no: rows.map { _1.version_no - 1 })
|
||||||
.pluck(:tag_group_id, :version_no, :id)
|
.pluck(:tag_group_id, :version_no, :id)
|
||||||
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
|
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
|
||||||
@@ -406,18 +406,18 @@ class WikiPagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def description_versions description
|
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)
|
language_code: description.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_description_by_title locale, title
|
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,
|
.find_by(language_code: locale.language_code,
|
||||||
tag_names: { language_code: locale.language_code, name: title })
|
tag_names: { language_code: locale.language_code, name: title })
|
||||||
end
|
end
|
||||||
|
|
||||||
def description_changes locale, descriptions
|
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))
|
tag_id: descriptions.select(:tag_id))
|
||||||
.order(id: :desc).limit(200).to_a
|
.order(id: :desc).limit(200).to_a
|
||||||
return [] if versions.empty?
|
return [] if versions.empty?
|
||||||
@@ -425,7 +425,7 @@ class WikiPagesController < ApplicationController
|
|||||||
by_tag = descriptions.where(tag_id: versions.map(&:tag_id))
|
by_tag = descriptions.where(tag_id: versions.map(&:tag_id))
|
||||||
.includes(tag: :tag_names).index_by(&:tag_id)
|
.includes(tag: :tag_names).index_by(&:tag_id)
|
||||||
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&: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,
|
language_code: locale.language_code, tag_id: by_tag.keys,
|
||||||
version_no: versions.map { |version| version.version_no - 1 })
|
version_no: versions.map { |version| version.version_no - 1 })
|
||||||
.pluck(:tag_id, :version_no, :id)
|
.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, -> {
|
scope :with_previous_version, -> {
|
||||||
joins('LEFT JOIN post_versions prev ON prev.post_id = post_versions.post_id ' +
|
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
|
belongs_to :post
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
class TagDescriptionVersion < ApplicationRecord
|
class TagDescriptionVersion < ApplicationRecord
|
||||||
|
include VersionRecord
|
||||||
|
|
||||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ module VersionRecord
|
|||||||
discard: 'discard',
|
discard: 'discard',
|
||||||
restore: 'restore' }.freeze
|
restore: 'restore' }.freeze
|
||||||
|
|
||||||
|
def require_history_access! = HistoryAccess.require!(self)
|
||||||
|
|
||||||
def readonly? = persisted?
|
def readonly? = persisted?
|
||||||
|
|
||||||
included do
|
included do
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class CountsVersionRepr
|
|||||||
|
|
||||||
def initialize(rows, resource:, language_code:, at:, inclusive:)
|
def initialize(rows, resource:, language_code:, at:, inclusive:)
|
||||||
@rows = rows
|
@rows = rows
|
||||||
|
HistoryAccess.require_many!(rows)
|
||||||
@at = at
|
@at = at
|
||||||
@inclusive = inclusive
|
@inclusive = inclusive
|
||||||
@resource = resource
|
@resource = resource
|
||||||
@@ -15,6 +16,7 @@ class CountsVersionRepr
|
|||||||
@scripts = master_names(Script)
|
@scripts = master_names(Script)
|
||||||
@tag_versions = {}
|
@tag_versions = {}
|
||||||
load_tag_versions if resource == 'posts'
|
load_tag_versions if resource == 'posts'
|
||||||
|
load_parent_versions if resource == 'tags'
|
||||||
end
|
end
|
||||||
|
|
||||||
def serialise
|
def serialise
|
||||||
@@ -47,7 +49,7 @@ class CountsVersionRepr
|
|||||||
.uniq
|
.uniq
|
||||||
pairs.each_slice(500) do |batch|
|
pairs.each_slice(500) do |batch|
|
||||||
placeholders = batch.map { '(?, ?)' }.join(', ')
|
placeholders = batch.map { '(?, ?)' }.join(', ')
|
||||||
scope = TagVersion.all
|
scope = HistoryAccess.visible(TagVersion.all)
|
||||||
if @at
|
if @at
|
||||||
scope = scope.where("created_at #{ @inclusive ? '<=' : '<' } ?", @at)
|
scope = scope.where("created_at #{ @inclusive ? '<=' : '<' } ?", @at)
|
||||||
end
|
end
|
||||||
@@ -86,6 +88,31 @@ class CountsVersionRepr
|
|||||||
original_created_before: row.original_created_before&.iso8601 }
|
original_created_before: row.original_created_before&.iso8601 }
|
||||||
end
|
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)
|
def tag(row)
|
||||||
recorded_names = names(row.names)
|
recorded_names = names(row.names)
|
||||||
selected = recorded_names.find { _1[:language_code] == @language }
|
selected = recorded_names.find { _1[:language_code] == @language }
|
||||||
@@ -97,7 +124,12 @@ class CountsVersionRepr
|
|||||||
end
|
end
|
||||||
{ name: selected&.fetch(:name) || row.name, names: recorded_names,
|
{ name: selected&.fetch(:name) || row.name, names: recorded_names,
|
||||||
category: row.category, aliases:,
|
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 }
|
deprecated: row.deprecated_at&.iso8601 }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ module TagGroupRepr
|
|||||||
end
|
end
|
||||||
|
|
||||||
def one locale, group
|
def one locale, group
|
||||||
|
HistoryAccess.require_current!(group, TagGroupVersion)
|
||||||
many(locale, [group]).first
|
many(locale, [group]).first
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -56,10 +57,11 @@ module TagGroupRepr
|
|||||||
end
|
end
|
||||||
|
|
||||||
def current_version group
|
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
|
end
|
||||||
|
|
||||||
def historical locale, version
|
def historical locale, version
|
||||||
|
HistoryAccess.require!(version)
|
||||||
members = Array(version.members)
|
members = Array(version.members)
|
||||||
placements = Array(version.placements)
|
placements = Array(version.placements)
|
||||||
tag_references = members.filter_map { |member| member['tag_id'] } +
|
tag_references = members.filter_map { |member| member['tag_id'] } +
|
||||||
@@ -153,13 +155,13 @@ module TagGroupRepr
|
|||||||
end
|
end
|
||||||
|
|
||||||
def historical_tag_versions ids
|
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]
|
[version.tag_id, version.version_no]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def historical_group_versions ids
|
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]
|
[version.tag_group_id, version.version_no]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ module WikiPageRepr
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def tag_description locale, description
|
def tag_description locale, description
|
||||||
|
HistoryAccess.require_current!(description, TagDescriptionVersion)
|
||||||
tag = description.tag
|
tag = description.tag
|
||||||
|
|
||||||
{ 'id' => description.id,
|
{ 'id' => description.id,
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ class CountsAxes
|
|||||||
def initialize(population)
|
def initialize(population)
|
||||||
@population = population
|
@population = population
|
||||||
@definition = population.definition
|
@definition = population.definition
|
||||||
@rows = parse(population.params.fetch('rows', '[]'))
|
@rows = parse(population.params.fetch('rows', ''))
|
||||||
@columns = parse(population.params.fetch('cols', '[]'))
|
@columns = parse(population.params.fetch('cols', ''))
|
||||||
@all = rows + columns
|
@all = rows + columns
|
||||||
raise ArgumentError if all.length > 8 || all.map { _1['field'] }.uniq.length != all.length
|
raise ArgumentError if all.length > 8 || all.map { _1['field'] }.uniq.length != all.length
|
||||||
end
|
end
|
||||||
@@ -81,7 +81,7 @@ class CountsAxes
|
|||||||
private
|
private
|
||||||
|
|
||||||
def parse(value)
|
def parse(value)
|
||||||
axes = JSON.parse(value)
|
axes = QueryState.axes(value)
|
||||||
raise ArgumentError unless axes.is_a?(Array)
|
raise ArgumentError unless axes.is_a?(Array)
|
||||||
|
|
||||||
axes.each do |axis|
|
axes.each do |axis|
|
||||||
|
|||||||
@@ -6,16 +6,23 @@ class CountsDefaults
|
|||||||
|
|
||||||
def self.entries(resource)
|
def self.entries(resource)
|
||||||
result = [{ definition: 'history', mode: 'summary', params: {
|
result = [{ definition: 'history', mode: 'summary', params: {
|
||||||
'rows' => [{ field: 'created_at', grain: 'month' }].to_json,
|
'rows' => 'created_at:month',
|
||||||
'cols' => [{ field: 'event_type' }].to_json,
|
'cols' => 'event_type',
|
||||||
'sort' => 'a0:desc', 'conditions' => '[]' } }]
|
'sort' => 'a0:desc', 'conditions' => '' } }]
|
||||||
|
result << { definition: 'latest', mode: 'summary', params: {
|
||||||
|
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
|
||||||
if resource == 'posts'
|
if resource == 'posts'
|
||||||
result += [{ definition: 'latest', mode: 'summary', params: {
|
result += [{ definition: 'latest', mode: 'details', params: {
|
||||||
'rows' => '[]', 'cols' => '[]', 'conditions' => '[]', 'tags' => '' } },
|
'columns' => POST_COLUMNS.join(' '), 'order' => POST_COLUMNS.join(' '),
|
||||||
{ definition: 'latest', mode: 'details', params: {
|
'children' => 'tags.name parents.id', 'nested_json' => '0',
|
||||||
'columns' => POST_COLUMNS.join(','), 'order' => POST_COLUMNS.join(','),
|
'conditions' => '', 'tags' => '' } }]
|
||||||
'children' => 'tags.name,parents.id', 'nested_json' => '0',
|
end
|
||||||
'conditions' => '[]', 'tags' => '' } }]
|
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
|
end
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
@@ -26,8 +33,8 @@ class CountsDefaults
|
|||||||
entry[:mode] == params.fetch('mode', 'summary')
|
entry[:mode] == params.fetch('mode', 'summary')
|
||||||
end&.fetch(:params) || {}
|
end&.fetch(:params) || {}
|
||||||
if settings.key?('sort') && params.key?('rows')
|
if settings.key?('sort') && params.key?('rows')
|
||||||
actual = JSON.parse(params['rows'])
|
actual = QueryState.axes(params['rows'])
|
||||||
expected = JSON.parse(settings.fetch('rows'))
|
expected = QueryState.axes(settings.fetch('rows'))
|
||||||
compatible = actual.is_a?(Array) && actual.first.is_a?(Hash) &&
|
compatible = actual.is_a?(Array) && actual.first.is_a?(Hash) &&
|
||||||
actual.first['field'] == expected.first['field'] &&
|
actual.first['field'] == expected.first['field'] &&
|
||||||
actual.first.fetch('grain', 'month') == expected.first['grain']
|
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)
|
def self.prepare(exporter, owner, progress: nil, check: nil, identifier: nil)
|
||||||
FileUtils.mkdir_p(directory, mode: 0o700)
|
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||||
|
authority = HistoryAccess.authority
|
||||||
slot = acquire_slot
|
slot = acquire_slot
|
||||||
|
|
||||||
begin
|
begin
|
||||||
generate(exporter, owner, progress:, check:, identifier:)
|
generate(exporter, owner, progress:, check:, identifier:, authority:)
|
||||||
ensure
|
ensure
|
||||||
slot.close
|
slot.close
|
||||||
end
|
end
|
||||||
@@ -40,7 +41,7 @@ class CountsExportDownload
|
|||||||
raise Busy
|
raise Busy
|
||||||
end
|
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)
|
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||||
Dir.glob(directory.join('counts-*.export')).each do |path|
|
Dir.glob(directory.join('counts-*.export')).each do |path|
|
||||||
File.unlink(path) if File.mtime(path) < Time.current - EXPIRY
|
File.unlink(path) if File.mtime(path) < Time.current - EXPIRY
|
||||||
@@ -80,7 +81,7 @@ class CountsExportDownload
|
|||||||
path = finished_path
|
path = finished_path
|
||||||
token = verifier.generate({ 'file' => File.basename(path),
|
token = verifier.generate({ 'file' => File.basename(path),
|
||||||
'resource' => exporter.resource, 'format' => exporter.format,
|
'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
|
complete = true
|
||||||
token
|
token
|
||||||
ensure
|
ensure
|
||||||
@@ -92,6 +93,7 @@ class CountsExportDownload
|
|||||||
def self.find(token, owner)
|
def self.find(token, owner)
|
||||||
metadata = verifier.verified(token, purpose: 'counts_export')
|
metadata = verifier.verified(token, purpose: 'counts_export')
|
||||||
return unless metadata.is_a?(Hash) && metadata['owner'] == owner
|
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 metadata['file']&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
|
||||||
return unless CountsResourceDefinition::DEFINITIONS.key?(metadata['resource'])
|
return unless CountsResourceDefinition::DEFINITIONS.key?(metadata['resource'])
|
||||||
return unless ['json', 'csv'].include?(metadata['format'])
|
return unless ['json', 'csv'].include?(metadata['format'])
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class CountsExportJobs
|
|||||||
'language' => language, 'resource' => exporter.resource,
|
'language' => language, 'resource' => exporter.resource,
|
||||||
'format' => exporter.format, 'status' => 'queued',
|
'format' => exporter.format, 'status' => 'queued',
|
||||||
'processed' => 0, 'total' => nil,
|
'processed' => 0, 'total' => nil,
|
||||||
|
**HistoryAccess.authority,
|
||||||
'expires_at' => (Time.current + CountsExportDownload::EXPIRY).to_f }
|
'expires_at' => (Time.current + CountsExportDownload::EXPIRY).to_f }
|
||||||
write(state)
|
write(state)
|
||||||
verifier.generate({ 'id' => id, 'owner' => owner },
|
verifier.generate({ 'id' => id, 'owner' => owner },
|
||||||
@@ -126,6 +127,8 @@ class CountsExportJobs
|
|||||||
state = read(id)
|
state = read(id)
|
||||||
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
|
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'])
|
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
|
||||||
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
|
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
|
||||||
remove_download(state)
|
remove_download(state)
|
||||||
@@ -182,6 +185,22 @@ class CountsExportJobs
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.perform(state)
|
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')
|
id = state.fetch('id')
|
||||||
token = nil
|
token = nil
|
||||||
begin
|
begin
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class CountsExportProjection
|
|||||||
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
|
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
|
||||||
'names' => NAME_FIELDS,
|
'names' => NAME_FIELDS,
|
||||||
'aliases' => ['languageCode', 'name', 'scriptCode'],
|
'aliases' => ['languageCode', 'name', 'scriptCode'],
|
||||||
'parents' => ['id'] }.freeze
|
'parents' => ['id', 'name'] }.freeze
|
||||||
|
|
||||||
def self.default_columns(resource)
|
def self.default_columns(resource)
|
||||||
fields = { 'posts' => ['title', 'url', 'tags', 'parents', 'video_ms'],
|
fields = { 'posts' => ['title', 'url', 'tags', 'parents', 'video_ms'],
|
||||||
@@ -56,7 +56,7 @@ class CountsExportProjection
|
|||||||
CountsQuery::COLUMNS.fetch(resource)
|
CountsQuery::COLUMNS.fetch(resource)
|
||||||
end
|
end
|
||||||
defaults = summary ? allowed : self.class.default_columns(resource)
|
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
|
raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns
|
||||||
|
|
||||||
@headers = JSON.parse(params.fetch('headers', JSON.generate(@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 })
|
@headers.all? { _1.is_a?(String) && _1.length <= 200 })
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
end
|
end
|
||||||
@order = params.fetch('order', '').split(',')
|
@order = QueryState.list(params.fetch('order', ''))
|
||||||
@sorts = params.fetch('child_sort', '').split(',').map do |entry|
|
@sorts = QueryState.list(params.fetch('child_sort', '')).map do |entry|
|
||||||
parts = entry.split(':', -1)
|
parts = entry.split(':', -1)
|
||||||
raise ArgumentError unless parts.length == 2
|
raise ArgumentError unless parts.length == 2
|
||||||
|
|
||||||
@@ -74,12 +74,28 @@ class CountsExportProjection
|
|||||||
|
|
||||||
[path, direction]
|
[path, direction]
|
||||||
end
|
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
|
self.class.default_children(resource).to_set
|
||||||
valid = CHILDREN.flat_map do |key, fields|
|
valid = CHILDREN.flat_map do |key, fields|
|
||||||
paths(fields, key)
|
paths(fields, key)
|
||||||
end
|
end
|
||||||
raise ArgumentError if @selected && (@selected.to_a - valid).any?
|
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|
|
allowed_sorts = valid.map do |path|
|
||||||
parts = path.split('.')
|
parts = path.split('.')
|
||||||
field = parts.pop
|
field = parts.pop
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class CountsExporter
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
|
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) }
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
@params = params
|
@params = params
|
||||||
@@ -65,8 +65,8 @@ class CountsExporter
|
|||||||
sort: export_sort,
|
sort: export_sort,
|
||||||
child_sort: sort_metadata(@params.fetch('child_sort', '')),
|
child_sort: sort_metadata(@params.fetch('child_sort', '')),
|
||||||
columns: @projection.metadata,
|
columns: @projection.metadata,
|
||||||
children: @params['children']&.split(','),
|
children: @params['children']&.split(' '),
|
||||||
field_order: @params.fetch('order', '').split(','), timezone: 'UTC',
|
field_order: @QueryState.list(params.fetch('order', '')), timezone: 'UTC',
|
||||||
count: @mode == 'summary' ? page[:group_count] : page[:count],
|
count: @mode == 'summary' ? page[:group_count] : page[:count],
|
||||||
totals: page[:totals], coverage: page[:coverage] }
|
totals: page[:totals], coverage: page[:coverage] }
|
||||||
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
|
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
|
||||||
@@ -103,7 +103,7 @@ class CountsExporter
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sort_metadata(value)
|
def sort_metadata(value)
|
||||||
value.split(',').map do |entry|
|
value.split(' ').map do |entry|
|
||||||
key, direction = entry.split(':', 2)
|
key, direction = entry.split(':', 2)
|
||||||
{ key:, direction: }
|
{ key:, direction: }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
class CountsPivotLayout
|
class CountsPivotLayout
|
||||||
def self.depths(keys, sorts, measures: true)
|
def self.depths(keys, sorts, measures: true)
|
||||||
return [] if keys.length < 2 || sorts.first&.first == 'count' ||
|
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' ||
|
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
|
order = (relevant + keys).uniq
|
||||||
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
|
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class CountsPopulation
|
|||||||
|
|
||||||
@scope = definition.model.all
|
@scope = definition.model.all
|
||||||
make_population
|
make_population
|
||||||
|
@scope = HistoryAccess.visible(@scope, 'v')
|
||||||
apply_filters
|
apply_filters
|
||||||
rescue TZInfo::InvalidTimezoneIdentifier
|
rescue TZInfo::InvalidTimezoneIdentifier
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
@@ -62,7 +63,8 @@ class CountsPopulation
|
|||||||
@scope = @scope.from("#{ table } v").select('v.*')
|
@scope = @scope.from("#{ table } v").select('v.*')
|
||||||
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
|
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
|
||||||
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
|
@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)
|
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
|
||||||
unless history
|
unless history
|
||||||
point = timestamp(params['at']) || at
|
point = timestamp(params['at']) || at
|
||||||
@@ -92,11 +94,11 @@ class CountsPopulation
|
|||||||
@scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i)
|
@scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i)
|
||||||
end
|
end
|
||||||
apply_tags if params['tags'].present? || params['tag'].present?
|
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
|
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
|
||||||
|
|
||||||
conditions.each { apply_condition(_1) }
|
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
|
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
|
||||||
|
|
||||||
drills.each do |entry|
|
drills.each do |entry|
|
||||||
@@ -190,7 +192,7 @@ class CountsPopulation
|
|||||||
return unless params['tags'].present?
|
return unless params['tags'].present?
|
||||||
|
|
||||||
locale = Locale.find_by!(code: params.fetch('locale', 'ja'))
|
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']
|
raw = params['tags']
|
||||||
literals = PostTagSearch.literals(locale, raw, references: refs)
|
literals = PostTagSearch.literals(locale, raw, references: refs)
|
||||||
predicates = literals.map do |literal|
|
predicates = literals.map do |literal|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CountsQuery
|
|||||||
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
||||||
@params = params
|
@params = params
|
||||||
@latest_expression = latest_expression
|
@latest_expression = latest_expression
|
||||||
|
scope = HistoryAccess.visible(scope)
|
||||||
@scope = scope.except(:select, :order).joins(
|
@scope = scope.except(:select, :order).joins(
|
||||||
"LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id")
|
"LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id")
|
||||||
raise ArgumentError unless (params.keys - KEYS).empty?
|
raise ArgumentError unless (params.keys - KEYS).empty?
|
||||||
@@ -33,7 +34,7 @@ class CountsQuery
|
|||||||
@limit = integer('limit', 20)
|
@limit = integer('limit', 20)
|
||||||
raise ArgumentError if limit > 100 || page > 1_000_000
|
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?
|
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
end
|
end
|
||||||
@@ -92,7 +93,7 @@ class CountsQuery
|
|||||||
value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc')
|
value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc')
|
||||||
return [] if value.empty?
|
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']
|
allowed = mode == 'details' ? COLUMNS.fetch(@resource) - ['thumbnail'] : ['group', 'count']
|
||||||
invalid = values.any? do |key, direction, *extra|
|
invalid = values.any? do |key, direction, *extra|
|
||||||
extra.any? || !allowed.include?(key) || !['asc', 'desc'].include?(direction)
|
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.
|
# Keep totals, rows, and detail pages on one repeatable-read database snapshot.
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
unless @history_loaded
|
unless @history_loaded
|
||||||
@post_start = PostVersion.minimum(:created_at)
|
@post_start = HistoryAccess.visible(PostVersion.all).minimum(:created_at)
|
||||||
@tag_start = TagVersion.minimum(:created_at)
|
@tag_start = HistoryAccess.visible(TagVersion.all).minimum(:created_at)
|
||||||
@history_loaded = true
|
@history_loaded = true
|
||||||
end
|
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,
|
coverage: { basis: 'recorded_snapshots', exact: false,
|
||||||
post_history_from: @post_start&.iso8601(6),
|
post_history_from: @post_start&.iso8601(6),
|
||||||
tag_history_from: @tag_start&.iso8601(6) } }
|
tag_history_from: @tag_start&.iso8601(6) } }
|
||||||
@@ -131,7 +132,10 @@ class CountsStateQuery
|
|||||||
end
|
end
|
||||||
|
|
||||||
def state_sql(table, boundary, live: false)
|
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
|
live ? "#{ sql } AND v.event_type <> 'discard'" : sql
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -350,7 +354,7 @@ class CountsStateQuery
|
|||||||
|
|
||||||
def order_sql(expressions, default, final)
|
def order_sql(expressions, default, final)
|
||||||
raw = @params.fetch('sort', default)
|
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.map(&:first).uniq.length != sorts.length
|
||||||
raise ArgumentError if sorts.length > expressions.length
|
raise ArgumentError if sorts.length > expressions.length
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ class CountsTableExport
|
|||||||
attr_reader :format, :resource
|
attr_reader :format, :resource
|
||||||
|
|
||||||
def initialize(params)
|
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) }
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
params = CountsDefaults.for(params).merge(params)
|
params = CountsDefaults.for(params).merge(params)
|
||||||
unless params.key?('children')
|
unless params.key?('children')
|
||||||
params = params.merge('children' => CountsExportProjection.default_children(
|
params = params.merge('children' => CountsExportProjection.default_children(
|
||||||
params.fetch('resource', 'posts')).join(','))
|
params.fetch('resource', 'posts')).join(' '))
|
||||||
end
|
end
|
||||||
@params = params
|
@params = params
|
||||||
@labels = JSON.parse(params.fetch('labels', '{}'))
|
@labels = JSON.parse(params.fetch('labels', '{}'))
|
||||||
raise ArgumentError unless @labels.is_a?(Hash)
|
raise ArgumentError unless @labels.is_a?(Hash)
|
||||||
@selected = params.key?('children') ? params['children'].split(',') : nil
|
@selected = params.key?('children') ? QueryState.list(params['children']) : nil
|
||||||
@order = params.fetch('order', '').split(',')
|
@order = QueryState.list(params.fetch('order', ''))
|
||||||
@child_sort = params.fetch('child_sort', '').split(',').map { _1.split(':', -1) }
|
@child_sort = QueryState.list(params.fetch('child_sort', '')).map { _1.split(':', -1) }
|
||||||
@child_sort.each do |path, direction, *extra|
|
@child_sort.each do |path, direction, *extra|
|
||||||
raise ArgumentError if extra.any? || !['asc', 'desc'].include?(direction)
|
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/)
|
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')
|
@resource = params.fetch('resource', 'posts')
|
||||||
raise ArgumentError unless ['json', 'csv'].include?(format)
|
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'))
|
.merge('page' => '1', 'limit' => '100'))
|
||||||
@summary = @query.mode == 'summary'
|
@summary = @query.mode == 'summary'
|
||||||
default_columns = @summary ? @query.axes.all.map { _1.fetch('field') } + ['count'] :
|
default_columns = @summary ? @query.axes.all.map { _1.fetch('field') } + ['count'] :
|
||||||
CountsExportProjection.default_columns(resource)
|
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) }
|
default_headers = @columns.map { @labels.fetch(_1, _1) }
|
||||||
@headers = JSON.parse(params.fetch('headers', default_headers.to_json))
|
@headers = JSON.parse(params.fetch('headers', default_headers.to_json))
|
||||||
raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) }
|
raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) }
|
||||||
@@ -66,7 +67,8 @@ class CountsTableExport
|
|||||||
unless started
|
unless started
|
||||||
metadata = page.except(:versions, :cells, :axis_labels, :column_values,
|
metadata = page.except(:versions, :cells, :axis_labels, :column_values,
|
||||||
:row_headers, :column_headers, :pivot_totals).merge(
|
: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,
|
fields: @query.population.definition.metadata,
|
||||||
columns: @columns, column_labels: @headers,
|
columns: @columns, column_labels: @headers,
|
||||||
order: @params['order'], children: @params['children'],
|
order: @params['order'], children: @params['children'],
|
||||||
@@ -138,13 +140,23 @@ class CountsTableExport
|
|||||||
end
|
end
|
||||||
|
|
||||||
def total_label(header)
|
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
|
end
|
||||||
|
|
||||||
def pivot_row_labels(header, page)
|
def pivot_row_labels(header, page)
|
||||||
values = header[:values].each_with_index.map { |value, index|
|
values = header[:values].each_with_index.map { |value, index|
|
||||||
summary_value(value, index, page)
|
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'
|
values << total_label(header) unless header[:kind] == 'data'
|
||||||
leading = [@query.axes.rows.length, @query.axes.columns.any? ? 1 : 0].max
|
leading = [@query.axes.rows.length, @query.axes.columns.any? ? 1 : 0].max
|
||||||
Array.new(leading) { values[_1] || '' }
|
Array.new(leading) { values[_1] || '' }
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ class CountsTableQuery
|
|||||||
|
|
||||||
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
|
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
|
||||||
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
|
'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
|
attr_reader :population, :axes, :page, :limit, :mode
|
||||||
|
|
||||||
def initialize(params)
|
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) }
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
params = CountsDefaults.for(params).slice(*KEYS).merge(params)
|
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 page.between?(1, 1_000_000) && limit.between?(1, 100)
|
||||||
raise ArgumentError unless ['summary', 'details'].include?(mode)
|
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'] :
|
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
|
||||||
@definition.fields.keys + ['is_latest'] +
|
@definition.fields.keys + ['is_latest'] +
|
||||||
(@definition.resource == 'posts' ? ['tags', 'parents'] : []) +
|
(@definition.resource == 'posts' ? ['tags', 'parents'] : []) +
|
||||||
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
|
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
|
||||||
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
|
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
|
||||||
@sort.each do |key, direction, *extra|
|
@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
|
measure_values(key) if measure
|
||||||
if extra.any? || (!allowed.include?(key) && !measure) || !['asc', 'desc'].include?(direction)
|
if extra.any? || (!allowed.include?(key) && !measure) || !['asc', 'desc'].include?(direction)
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
@@ -46,7 +46,8 @@ class CountsTableQuery
|
|||||||
end
|
end
|
||||||
|
|
||||||
def metadata
|
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',
|
definition: population.history ? 'history' : 'latest',
|
||||||
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
|
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
|
||||||
fields: @definition.metadata,
|
fields: @definition.metadata,
|
||||||
@@ -76,7 +77,7 @@ class CountsTableQuery
|
|||||||
measure_joins = []
|
measure_joins = []
|
||||||
extras = []
|
extras = []
|
||||||
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
|
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)
|
values = key == 'count' ? [] : measure_values(key)
|
||||||
relation = measure_relation(values)
|
relation = measure_relation(values)
|
||||||
matches = row_keys.map { "grouped.#{ _1 } <=> sort#{ index }.#{ _1 }" }
|
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_values = records.map { |record| row_keys.map { record[_1] } }.uniq
|
||||||
row_depths = CountsPivotLayout.depths(row_keys, @sort)
|
row_depths = CountsPivotLayout.depths(row_keys, @sort)
|
||||||
column_keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
|
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,
|
row_headers = CountsPivotLayout.headers(row_values, row_depths,
|
||||||
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?)
|
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?)
|
||||||
if axes.rows.empty? && axes.columns.any?
|
if axes.rows.empty? && axes.columns.any?
|
||||||
@@ -197,6 +203,15 @@ class CountsTableQuery
|
|||||||
order = directions.map { |key, direction|
|
order = directions.map { |key, direction|
|
||||||
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
|
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
|
||||||
}.join(', ')
|
}.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)
|
values = @connection.select_rows(<<~SQL)
|
||||||
#{ prefix }
|
#{ prefix }
|
||||||
SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
|
SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
|
||||||
@@ -225,7 +240,11 @@ class CountsTableQuery
|
|||||||
end
|
end
|
||||||
|
|
||||||
def measure_values(key)
|
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 &&
|
unless (value.is_a?(Array) && value.length <= axes.columns.length &&
|
||||||
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
|
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Shared literal parsing and reference resolution for current and snapshot post search.
|
# Shared literal parsing and reference resolution for current and snapshot post search.
|
||||||
class PostTagSearch
|
class PostTagSearch
|
||||||
def self.parse_references(raw, literals)
|
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|
|
valid = references.is_a?(Array) && references.all? do |reference|
|
||||||
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
|
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
|
# 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
|
## Scope
|
||||||
|
|
||||||
These rules apply to work under `frontend/`.
|
These rules apply to work under `frontend/`.
|
||||||
|
|||||||
+14
-3
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
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'
|
useLayoutEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { createBrowserRouter,
|
import { createBrowserRouter,
|
||||||
@@ -267,8 +267,19 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
? resolve ({ location, params: route.params, locale, targetLocale })
|
? resolve ({ location, params: route.params, locale, targetLocale })
|
||||||
: switchLocalePath (location, targetLocale)
|
: switchLocalePath (location, targetLocale)
|
||||||
}
|
}
|
||||||
const [user, setUser] = useState<User | null> (
|
const [user, setUserState] = useState<User | null> (
|
||||||
() => client.getQueryData<User> (sessionKeys.current) ?? 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 [status, setStatus] = useState (200)
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
@@ -317,7 +328,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
}
|
}
|
||||||
}) ()
|
}) ()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [setUser])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(user))
|
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 { useClientMotion } from '@/lib/useClientMotion'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
|
||||||
type Name = { text: string; language: string }
|
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
|
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 motion = useClientMotion ()
|
||||||
const item = useSortable ({ id, disabled: fixed,
|
const item = useSortable ({ id, disabled: fixed,
|
||||||
transition: { duration: motion.duration ('reorder'), easing: motion.easing },
|
transition: { duration: motion.duration ('reorder'), easing: motion.easing },
|
||||||
animateLayoutChanges: options => !motion.reduced
|
animateLayoutChanges: options => !motion.reduced
|
||||||
&& defaultAnimateLayoutChanges ({ ...options, wasDragging: true }) })
|
&& defaultAnimateLayoutChanges ({ ...options, wasDragging: true }) })
|
||||||
return <div ref={item.setNodeRef}
|
const handle = fixed ? <span className="w-6 shrink-0"/> : <button type="button"
|
||||||
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"
|
|
||||||
id={handleId} ref={item.setActivatorNodeRef} {...item.attributes} {...item.listeners}
|
id={handleId} ref={item.setActivatorNodeRef} {...item.attributes} {...item.listeners}
|
||||||
aria-label={name.text} lang={name.language}
|
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
|
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
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
|
||||||
active:cursor-grabbing dark:focus-visible:ring-blue-400"
|
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)
|
moveGroup (event.key === 'ArrowLeft' ? -1 : 1)
|
||||||
}
|
}
|
||||||
else item.listeners?.onKeyDown?.(event)
|
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 className="min-w-0 flex-1 py-1">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { useEffect, useState } from 'react'
|
|||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsEventTypes, countsFieldLabel, countsFilterFields,
|
import { countsEventTypes, countsFieldLabel, countsFilterFields,
|
||||||
readCountsJSON, useCountsDefinition }
|
useCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
|
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
@@ -65,8 +67,8 @@ const CountsAdditionalConditions = ({ params, update }: {
|
|||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const definition = useCountsDefinition ()
|
const definition = useCountsDefinition ()
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const conditions = readCountsJSON<CountsCondition[]> (params, 'conditions', [])
|
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
|
||||||
const write = (next: CountsCondition[]) => update ('conditions', JSON.stringify (next))
|
const write = (next: CountsCondition[]) => update ('conditions', encodeCountState ('conditions', next))
|
||||||
const edit = (index?: number) => {
|
const edit = (index?: number) => {
|
||||||
const initial = index == null ? undefined : conditions[index]
|
const initial = index == null ? undefined : conditions[index]
|
||||||
const save = (condition: CountsCondition) => {
|
const save = (condition: CountsCondition) => {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import LocalisedText from '@/components/LocalisedText'
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
import SortableList from '@/components/common/SortableList'
|
import SortableList from '@/components/common/SortableList'
|
||||||
import { countsAxisFields, countsAxisLabel, countsFilterFields,
|
import { countsAxisFields, countsAxisLabel, countsFilterFields,
|
||||||
readCountsJSON, useCountsDefinition }
|
useCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import { useClientMotion } from '@/lib/useClientMotion'
|
import { useClientMotion } from '@/lib/useClientMotion'
|
||||||
import { cn, inputClass } from '@/lib/utils'
|
import { cn, inputClass } from '@/lib/utils'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +21,8 @@ const CountsAxes = ({ params, update }: {
|
|||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const motion = useClientMotion ()
|
const motion = useClientMotion ()
|
||||||
const available = countsAxisFields (definition, params.get ('definition') === 'history')
|
const available = countsAxisFields (definition, params.get ('definition') === 'history')
|
||||||
const rows = readCountsJSON<CountsAxis[]> (params, 'rows', [])
|
const rows = readCountState<CountsAxis[]> (params, 'rows', [])
|
||||||
const cols = readCountsJSON<CountsAxis[]> (params, 'cols', [])
|
const cols = readCountState<CountsAxis[]> (params, 'cols', [])
|
||||||
const axes = [...rows, ...cols]
|
const axes = [...rows, ...cols]
|
||||||
const groups = {
|
const groups = {
|
||||||
available: available.map (field => field.key).filter (id => !axes.some (
|
available: available.map (field => field.key).filter (id => !axes.some (
|
||||||
@@ -33,7 +35,7 @@ const CountsAxes = ({ params, update }: {
|
|||||||
}
|
}
|
||||||
const commit = (next: Record<string, string[]>) => {
|
const commit = (next: Record<string, string[]>) => {
|
||||||
for (const group of ['rows', 'cols'] as const)
|
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,
|
axis => axis.field === field) ?? { field,
|
||||||
...(countsFilterFields (definition)[field] === 'datetime' ? { grain: 'month' } : { }) })))
|
...(countsFilterFields (definition)[field] === 'datetime' ? { grain: 'month' } : { }) })))
|
||||||
}
|
}
|
||||||
@@ -73,7 +75,7 @@ const CountsAxes = ({ params, update }: {
|
|||||||
{countsFilterFields (definition)[id] === 'datetime' && <select
|
{countsFilterFields (definition)[id] === 'datetime' && <select
|
||||||
className={inputClass (false, 'w-auto py-0')}
|
className={inputClass (false, 'w-auto py-0')}
|
||||||
value={axis?.grain ?? 'month'} aria-label={message ('grain').text}
|
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
|
(group === 'rows' ? rows : cols).map (item => item.field === id
|
||||||
? { ...item, grain: event.target.value } : item)))}>
|
? { ...item, grain: event.target.value } : item)))}>
|
||||||
{definition.grains.map (grain => <option key={grain} value={grain}
|
{definition.grains.map (grain => <option key={grain} value={grain}
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
|
import { countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
|
||||||
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
|
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
|
||||||
import { countsNestedIsScalar } from '@/lib/countsDisplay'
|
import { countsNestedIsScalar } from '@/lib/countsDisplay'
|
||||||
import { readCountsJSON, useCountsDefinition } from '@/lib/countsDefinitions'
|
import { useCountsDefinition } from '@/lib/countsDefinitions'
|
||||||
import { useClientMotion } from '@/lib/useClientMotion'
|
import { useClientMotion } from '@/lib/useClientMotion'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import { readCountState, setCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import type { CountsResource } from '@/lib/counts'
|
import type { CountsResource } from '@/lib/counts'
|
||||||
@@ -59,7 +61,7 @@ const CountsControls = (
|
|||||||
const next = new URLSearchParams (previous)
|
const next = new URLSearchParams (previous)
|
||||||
const columns = selectedCountColumns (resource, previous, definition)
|
const columns = selectedCountColumns (resource, previous, definition)
|
||||||
.filter (column => column !== 'thumbnail')
|
.filter (column => column !== 'thumbnail')
|
||||||
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (','))
|
next.set ('columns', (thumbnail ? ['thumbnail', ...columns] : columns).join (' '))
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [thumbnail, resource, definition])
|
}, [thumbnail, resource, definition])
|
||||||
@@ -79,16 +81,16 @@ const CountsControls = (
|
|||||||
const update = (key: string, value: string) => setDraft (previous => {
|
const update = (key: string, value: string) => setDraft (previous => {
|
||||||
const next = new URLSearchParams (previous)
|
const next = new URLSearchParams (previous)
|
||||||
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
|
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
|
||||||
next.set (key, value)
|
setCountState (next, key, value)
|
||||||
if (key !== 'mode') return next
|
if (key !== 'mode') return next
|
||||||
const resolved = normaliseCountsParams (resource, next, definition)
|
const resolved = normaliseCountsParams (resource, next, definition)
|
||||||
const axes = [...readCountsJSON<unknown[]> (resolved, 'rows', []),
|
const axes = [...readCountState<unknown[]> (resolved, 'rows', []),
|
||||||
...readCountsJSON<unknown[]> (resolved, 'cols', [])]
|
...readCountState<unknown[]> (resolved, 'cols', [])]
|
||||||
const allowed = value === 'summary'
|
const allowed = value === 'summary'
|
||||||
? ['count', ...axes.map ((_, index) => `a${index}`)]
|
? ['count', ...axes.map ((_, index) => `a${index}`)]
|
||||||
: fieldsForCounts (resource, definition).map (field => field.key)
|
: fieldsForCounts (resource, definition).map (field => field.key)
|
||||||
if (readCountSort (resolved.get ('sort')).some (sort => !allowed.includes (sort.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')
|
next.delete ('sort')
|
||||||
return normaliseCountsParams (resource, next, definition)
|
return normaliseCountsParams (resource, next, definition)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useId, useState } from 'react'
|
|||||||
|
|
||||||
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
|
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
|
||||||
import SortableList from '@/components/common/SortableList'
|
import SortableList from '@/components/common/SortableList'
|
||||||
import { countChildPaths, fieldsForCounts, orderedCountFields,
|
import { fieldsForCounts, orderedCountFields,
|
||||||
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
|
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
|
||||||
import { useCountsDefinition } from '@/lib/countsDefinitions'
|
import { useCountsDefinition } from '@/lib/countsDefinitions'
|
||||||
import { useClientMotion } from '@/lib/useClientMotion'
|
import { useClientMotion } from '@/lib/useClientMotion'
|
||||||
@@ -26,7 +26,8 @@ const FieldBranch = ({ field, path, enabled, selected, params, select, update }:
|
|||||||
return <>
|
return <>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<label className="inline-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)}/>
|
onChange={event => select (path, event.target.checked)}/>
|
||||||
{label (field.label)}
|
{label (field.label)}
|
||||||
</label>
|
</label>
|
||||||
@@ -43,7 +44,7 @@ const FieldBranch = ({ field, path, enabled, selected, params, select, update }:
|
|||||||
{field.children != null && <AnimatedCollapse open={open} id={id}>
|
{field.children != null && <AnimatedCollapse open={open} id={id}>
|
||||||
<div className="ml-2 border-l pl-1 dark:border-slate-700">
|
<div className="ml-2 border-l pl-1 dark:border-slate-700">
|
||||||
<FieldGroup fields={field.children} path={path} params={params}
|
<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>
|
</div>
|
||||||
</AnimatedCollapse>}
|
</AnimatedCollapse>}
|
||||||
</>
|
</>
|
||||||
@@ -65,9 +66,9 @@ const FieldGroup = ({ fields, path, params, enabled, selected, select, update }:
|
|||||||
renderGroup={(_, content) => content}
|
renderGroup={(_, content) => content}
|
||||||
onChange={groups => {
|
onChange={groups => {
|
||||||
const current = fields.map (field => `${prefix}${field.key}`)
|
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))
|
.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 => {
|
}} renderItem={key => {
|
||||||
const field = fields.find (field => field.key === key)!
|
const field = fields.find (field => field.key === key)!
|
||||||
return <FieldBranch field={field} path={`${prefix}${key}`} enabled={enabled}
|
return <FieldBranch field={field} path={`${prefix}${key}`} enabled={enabled}
|
||||||
@@ -101,13 +102,28 @@ const CountsFieldTree = (
|
|||||||
if (checked) next.add (root.key)
|
if (checked) next.add (root.key)
|
||||||
else next.delete (root.key)
|
else next.delete (root.key)
|
||||||
update (selectionKey, fields.filter (field => next.has (field.key))
|
update (selectionKey, fields.filter (field => next.has (field.key))
|
||||||
.map (field => field.key).join (','))
|
.map (field => field.key).join (' '))
|
||||||
return
|
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)
|
else selected.delete (path)
|
||||||
update ('children', countChildPaths (fields).filter (
|
const nextParams = new URLSearchParams (params)
|
||||||
key => key.includes ('.') && selected.has (key)).join (','))
|
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 === ''
|
const visibleFields = parent === ''
|
||||||
? fields.filter (field => field.key !== 'thumbnail') : fields
|
? fields.filter (field => field.key !== 'thumbnail') : fields
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import SortableColumns from '@/components/common/SortableColumns'
|
||||||
import LocalisedText from '@/components/LocalisedText'
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import CountsRowNumber from '@/components/counts/CountsRowNumber'
|
import CountsRowNumber from '@/components/counts/CountsRowNumber'
|
||||||
@@ -33,7 +34,6 @@ const CountsResults = (
|
|||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const columns = selectedCountColumns (resource, params, definition)
|
const columns = selectedCountColumns (resource, params, definition)
|
||||||
const rowNumbers = params.get ('row_numbers') === '1'
|
const rowNumbers = params.get ('row_numbers') === '1'
|
||||||
const tableColumns = rowNumbers ? ['row_number', ...columns] : columns
|
|
||||||
const fields = fieldsForCounts (resource, definition)
|
const fields = fieldsForCounts (resource, definition)
|
||||||
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
|
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
|
||||||
const sort = (key: string, additive: boolean) =>
|
const sort = (key: string, additive: boolean) =>
|
||||||
@@ -104,7 +104,15 @@ const CountsResults = (
|
|||||||
default: return value
|
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}>
|
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||||
{tableColumns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
{tableColumns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||||
<CountsSelectableTable family={resource}
|
<CountsSelectableTable family={resource}
|
||||||
@@ -112,7 +120,7 @@ const CountsResults = (
|
|||||||
copyTable={() => countsDetailClipboardTable (data.versions ?? [],
|
copyTable={() => countsDetailClipboardTable (data.versions ?? [],
|
||||||
columns.map (column => fields.find (field => field.key === column)!),
|
columns.map (column => fields.find (field => field.key === column)!),
|
||||||
params, locale, rowNumbers ? (data.page - 1) * data.limit + 1 : null)}
|
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)}`}
|
(data.versions ?? []).map (row => row.id)}`}
|
||||||
className="text-left text-sm">
|
className="text-left text-sm">
|
||||||
<thead className="sticky top-0 z-20 bg-background text-foreground
|
<thead className="sticky top-0 z-20 bg-background text-foreground
|
||||||
@@ -126,6 +134,7 @@ const CountsResults = (
|
|||||||
</th>
|
</th>
|
||||||
: <CountsSortHeader key={column} field={column}
|
: <CountsSortHeader key={column} field={column}
|
||||||
label={message (fields.find (field => field.key === column)!.label)}
|
label={message (fields.find (field => field.key === column)!.label)}
|
||||||
|
dragId={column}
|
||||||
sorts={sorts} change={sort} structured={fields.find (
|
sorts={sorts} change={sort} structured={fields.find (
|
||||||
field => field.key === column)?.children != null}/>)}
|
field => field.key === column)?.children != null}/>)}
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
@@ -148,6 +157,7 @@ const CountsResults = (
|
|||||||
{(data.versions?.length ?? 0) === 0
|
{(data.versions?.length ?? 0) === 0
|
||||||
&& <p className="p-2">{label ('empty')}</p>}
|
&& <p className="p-2">{label ('empty')}</p>}
|
||||||
</div>)
|
</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 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) =>
|
const cellAt = (table: HTMLTableElement, point: Point) =>
|
||||||
table.tBodies[0]?.rows[point.row]?.cells[point.column]
|
cellGrid (table)[point.row]?.[point.column]
|
||||||
|
|
||||||
|
|
||||||
const CountsSelectableTable = (
|
const CountsSelectableTable = (
|
||||||
@@ -66,31 +87,48 @@ const CountsSelectableTable = (
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
useLayoutEffect (() => {
|
useLayoutEffect (() => {
|
||||||
const rows = root.current?.tBodies[0]?.rows
|
if (root.current == null) return
|
||||||
if (rows == null) return
|
const grid = cellGrid (root.current)
|
||||||
for (const [row, tr] of Array.from (rows).entries ())
|
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
|
if (assigned.has (cell)) continue
|
||||||
&& column >= bounds.left && column <= bounds.right
|
assigned.add (cell)
|
||||||
cell.id = `${id}-${row}-${column}`
|
const selected = bounds != null && row <= bounds.bottom
|
||||||
cell.setAttribute ('role', 'gridcell')
|
&& row + cell.rowSpan - 1 >= bounds.top && column <= bounds.right
|
||||||
cell.setAttribute ('aria-selected', String (selected))
|
&& column + cell.colSpan - 1 >= bounds.left
|
||||||
if (selected) cell.dataset.countsSelected = ''
|
cell.id = `${id}-${row}-${column}`
|
||||||
else delete cell.dataset.countsSelected
|
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])
|
}, [bounds, children, id])
|
||||||
const pointFor = (element: Element | null): Point | null => {
|
const pointFor = (element: Element | null, y?: number): Point | null => {
|
||||||
const table = root.current
|
const table = root.current
|
||||||
if (table == null || element == null || !table.contains (element)) return null
|
if (table == null || element == null || !table.contains (element)) return null
|
||||||
let cell = element.closest ('td')
|
let cell = element.closest ('td')
|
||||||
while (cell != null && cell.closest ('table') !== table)
|
while (cell != null && cell.closest ('table') !== table)
|
||||||
cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null
|
cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null
|
||||||
if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null
|
if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null
|
||||||
return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex,
|
const grid = cellGrid (table)
|
||||||
column: (cell as HTMLTableCellElement).cellIndex }
|
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 (() => {
|
const closeMenu = useCallback (() => {
|
||||||
setMenu (null)
|
setMenu (null)
|
||||||
@@ -181,8 +219,7 @@ const CountsSelectableTable = (
|
|||||||
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
|
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
|
||||||
aria-label={name?.text ?? message ('selection.table').text}
|
aria-label={name?.text ?? message ('selection.table').text}
|
||||||
aria-describedby={`${id}-hint`}
|
aria-describedby={`${id}-hint`}
|
||||||
aria-activedescendant={selection == null ? undefined
|
|
||||||
: `${id}-${selection.end.row}-${selection.end.column}`}
|
|
||||||
style={{ tableLayout: 'fixed', width: totalWidth }}
|
style={{ tableLayout: 'fixed', width: totalWidth }}
|
||||||
className={cn ('counts-grid select-none border-separate border-spacing-0 outline-none',
|
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',
|
'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
|
if (event.clientX >= rect.left + element.clientLeft + element.clientWidth
|
||||||
|| event.clientY >= rect.top + element.clientTop + element.clientHeight) return
|
|| event.clientY >= rect.top + element.clientTop + element.clientHeight) return
|
||||||
}
|
}
|
||||||
const point = pointFor (element)
|
const point = pointFor (element, event.clientY)
|
||||||
if (point == null) return
|
if (point == null) return
|
||||||
event.stopPropagation ()
|
event.stopPropagation ()
|
||||||
setMenu (null)
|
setMenu (null)
|
||||||
@@ -246,7 +283,7 @@ const CountsSelectableTable = (
|
|||||||
event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8)
|
event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8)
|
||||||
finish ()
|
finish ()
|
||||||
if (!dragging.current || event.buttons === 0) return
|
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 (point == null) return
|
||||||
if (touchArmed.current && touchPress.current != null
|
if (touchArmed.current && touchPress.current != null
|
||||||
&& Math.hypot (event.clientX - touchPress.current.x,
|
&& Math.hypot (event.clientX - touchPress.current.x,
|
||||||
@@ -263,7 +300,7 @@ const CountsSelectableTable = (
|
|||||||
onContextMenu={event => {
|
onContextMenu={event => {
|
||||||
const element = event.target as Element
|
const element = event.target as Element
|
||||||
if (element.closest ('table') !== event.currentTarget) return
|
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
|
if (point == null || element.closest ('[data-counts-resize]') != null) return
|
||||||
event.preventDefault ()
|
event.preventDefault ()
|
||||||
event.stopPropagation ()
|
event.stopPropagation ()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import SortableItem from '@/components/common/SortableItem'
|
||||||
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
@@ -8,13 +9,14 @@ import type { CountSort } from '@/lib/counts'
|
|||||||
|
|
||||||
|
|
||||||
const CountsSortHeader = (
|
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
|
field: string
|
||||||
label: { text: string; language: string }
|
label: { text: string; language: string }
|
||||||
sorts: CountSort[]
|
sorts: CountSort[]
|
||||||
change: (key: string, additive: boolean) => void
|
change: (key: string, additive: boolean) => void
|
||||||
structured?: boolean
|
structured?: boolean
|
||||||
rowSpan?: number; colSpan?: number; resizable?: boolean },
|
rowSpan?: number; colSpan?: number; resizable?: boolean
|
||||||
|
dragId?: string; totalKind?: string },
|
||||||
) => {
|
) => {
|
||||||
const { message } = useUITranslation ('counts')
|
const { message } = useUITranslation ('counts')
|
||||||
const press = useRef<{ x: number; y: number
|
const press = useRef<{ x: number; y: number
|
||||||
@@ -36,10 +38,14 @@ const CountsSortHeader = (
|
|||||||
column: label.text, priority: priority + 1,
|
column: label.text, priority: priority + 1,
|
||||||
direction: message (`sort.${state}`).text })
|
direction: message (`sort.${state}`).text })
|
||||||
|
|
||||||
return (
|
const render = (shell?: Parameters<NonNullable<
|
||||||
<th data-counts-heading={label.text} rowSpan={rowSpan} colSpan={colSpan}
|
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"
|
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
|
||||||
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
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"
|
<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"
|
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}
|
lang={aria.language} aria-label={aria.text} title={title.text}
|
||||||
@@ -90,8 +96,11 @@ const CountsSortHeader = (
|
|||||||
{priority >= 0 && <span aria-hidden>
|
{priority >= 0 && <span aria-hidden>
|
||||||
{direction === 'asc' ? '▲' : '▼'}{sorts.length > 1 && priority + 1}</span>}
|
{direction === 'asc' ? '▲' : '▼'}{sorts.length > 1 && priority + 1}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
{resizable && <CountsResizeHandle field={field} label={label}/>}
|
{resizable && <CountsResizeHandle field={field} label={label}/>}
|
||||||
</th>)
|
</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 PrefetchLink from '@/components/PrefetchLink'
|
||||||
import CountsRowNumber from '@/components/counts/CountsRowNumber'
|
import CountsRowNumber from '@/components/counts/CountsRowNumber'
|
||||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||||
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
||||||
import { countsAxisLabel, countsFieldLabel, readCountsJSON, useCountsDefinition }
|
import { countsAxisLabel, useCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
|
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
|
||||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
|
|
||||||
const CountsSummaryResults = (
|
const CountsSummaryTable = (
|
||||||
{ resource, data, params, pending, update }: {
|
{ resource, data, params, pending, update, dragIds = {} }: {
|
||||||
|
dragIds?: Record<string, string>
|
||||||
resource: CountsResource; data: CountsResult; params: URLSearchParams
|
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 definition = useCountsDefinition ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
@@ -33,30 +38,38 @@ const CountsSummaryResults = (
|
|||||||
const sorts = readCountSort (params.get ('sort'))
|
const sorts = readCountSort (params.get ('sort'))
|
||||||
const change = (key: string, additive: boolean) => update (
|
const change = (key: string, additive: boolean) => update (
|
||||||
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
||||||
const display = (value: string | number | null, axis: CountsAxis, index: number): string => {
|
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
|
||||||
if (value == null) return axis.field === 'user' ? message ('bot').text : ''
|
countsSummaryValue (value, axis, index, data, locale)
|
||||||
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 aggregateLabel = (header: CountsPivotHeader) => message (
|
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 leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
|
||||||
const levels = Math.max (1, cols.length)
|
const levels = Math.max (1, cols.length)
|
||||||
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
|
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
|
||||||
header.values.map ((value, index) => display (value, axes[index], offset + index))
|
header.values.map ((value, index) => display (value, axes[index], offset + index))
|
||||||
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
|
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
|
||||||
const measureKey = (header: CountsPivotHeader) => header.values.length === 0 ? 'count'
|
const measures = readCountState<Record<string, string | number | null>[]> (
|
||||||
: `measure_${[...new TextEncoder ().encode (JSON.stringify (header.values))]
|
params, 'measures', [])
|
||||||
.map (byte => byte.toString (16).padStart (2, '0')).join ('')}`
|
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'] : []),
|
const keys = [...(numbered ? ['row_number'] : []),
|
||||||
...Array.from ({ length: leading }, (_, index) => `a${index}`),
|
...Array.from ({ length: leading }, (_, index) => `a${index}`),
|
||||||
...columnHeaders.map (columnKey)]
|
...columnHeaders.map (columnKey)]
|
||||||
@@ -69,8 +82,8 @@ const CountsSummaryResults = (
|
|||||||
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
|
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
|
||||||
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
|
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
|
||||||
const next = new URLSearchParams (params)
|
const next = new URLSearchParams (params)
|
||||||
const existing = readCountsJSON<unknown[]> (params, 'drill', [])
|
const existing = readCountState<Record<string, string | number | null>[]> (params, 'drill', [])
|
||||||
next.set ('drill', JSON.stringify ([...existing,
|
setCountState (next, 'drill', encodeCountState ('drill', [...existing,
|
||||||
...row.values.map ((value, index) => ({ ...rows[index], value })),
|
...row.values.map ((value, index) => ({ ...rows[index], value })),
|
||||||
...column.values.map ((value, index) => ({ ...cols[index], value }))]))
|
...column.values.map ((value, index) => ({ ...cols[index], value }))]))
|
||||||
next.set ('mode', 'details')
|
next.set ('mode', 'details')
|
||||||
@@ -86,6 +99,25 @@ const CountsSummaryResults = (
|
|||||||
return [...(numbered ? [number] : []),
|
return [...(numbered ? [number] : []),
|
||||||
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
|
...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.
|
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
|
||||||
const headingRows = Array.from ({ length: levels }, (_, level) => [
|
const headingRows = Array.from ({ length: levels }, (_, level) => [
|
||||||
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
|
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
|
||||||
@@ -134,9 +166,13 @@ const CountsSummaryResults = (
|
|||||||
const field = leaf ? columnKey (header) : sortKey
|
const field = leaf ? columnKey (header) : sortKey
|
||||||
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
|
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
|
||||||
colSpan={span} rowSpan={aggregate ? levels - level : 1} resizable={leaf}
|
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 }}
|
label={{ text, language: message ('count').language }}
|
||||||
sorts={sorts.map (sort => sort.key === sortKey ? { ...sort, key: field } : sort)}
|
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
|
index += span - 1
|
||||||
}
|
}
|
||||||
return output
|
return output
|
||||||
@@ -161,14 +197,21 @@ const CountsSummaryResults = (
|
|||||||
</tr>)}
|
</tr>)}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
|
||||||
{rowLabels[index].map ((value, offset) => <td key={offset} data-counts-copy={value}
|
{rowLabels[index].map ((value, offset) => {
|
||||||
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
|
const axis = offset - (numbered ? 1 : 0)
|
||||||
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
|
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
|
||||||
row.kind !== 'data' && 'bg-slate-200 text-slate-950 font-semibold',
|
return span === 0 ? null : <td key={offset} rowSpan={span}
|
||||||
row.kind !== 'data' && 'dark:bg-slate-700 dark:text-slate-50',
|
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
|
||||||
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>)}
|
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)}
|
{columnHeaders.map (column => <td key={columnKey (column)}
|
||||||
data-counts-copy={String (count (row, 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')
|
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
||||||
? '' : undefined}
|
? '' : undefined}
|
||||||
className={cn ('border px-2 py-1 dark:border-slate-700',
|
className={cn ('border px-2 py-1 dark:border-slate-700',
|
||||||
@@ -183,4 +226,30 @@ const CountsSummaryResults = (
|
|||||||
</div>
|
</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
|
export default CountsSummaryResults
|
||||||
|
|||||||
@@ -54,3 +54,26 @@
|
|||||||
.counts-nested {
|
.counts-nested {
|
||||||
@apply border-l-2 border-slate-300 dark:border-slate-600;
|
@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 { apiDownload, apiGet } from '@/lib/api'
|
||||||
|
|
||||||
|
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
import type { TagNameSnapshot } from '@/types'
|
import type { TagNameSnapshot } from '@/types'
|
||||||
@@ -58,6 +60,8 @@ export type CountsStateTotals = {
|
|||||||
export type CountsPivotHeader = { values: (string | number | null)[]
|
export type CountsPivotHeader = { values: (string | number | null)[]
|
||||||
kind: 'data' | 'subtotal' | 'grand_total' }
|
kind: 'data' | 'subtotal' | 'grand_total' }
|
||||||
export type CountsResult = {
|
export type CountsResult = {
|
||||||
|
populationCount?: number
|
||||||
|
populationCountScope?: string
|
||||||
rowHeaders?: CountsPivotHeader[]
|
rowHeaders?: CountsPivotHeader[]
|
||||||
columnHeaders?: CountsPivotHeader[]
|
columnHeaders?: CountsPivotHeader[]
|
||||||
pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[]
|
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',
|
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
|
||||||
original_created_from: 'counts:columns.originalFrom',
|
original_created_from: 'counts:columns.originalFrom',
|
||||||
original_created_before: 'counts:columns.originalBefore',
|
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',
|
category: 'common:categoryLabel', aliases: 'tags:aliases',
|
||||||
deprecated: 'counts:columns.deprecated', user: 'history:actor',
|
deprecated: 'counts:columns.deprecated', user: 'history:actor',
|
||||||
created_at: 'counts:columns.changedAt' }
|
created_at: 'counts:columns.changedAt' }
|
||||||
@@ -165,7 +169,9 @@ export const fieldsForCounts = (
|
|||||||
key,
|
key,
|
||||||
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
|
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
|
||||||
: (countColumns[key] ?? countsFieldLabel (key)),
|
: (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 = (
|
export const orderedCountFields = (
|
||||||
@@ -174,7 +180,7 @@ export const orderedCountFields = (
|
|||||||
parent = '',
|
parent = '',
|
||||||
): CountField[] => {
|
): CountField[] => {
|
||||||
const prefix = parent ? `${parent}.` : ''
|
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 keys = [...new Set ([...saved, ...fields.map (field => `${prefix}${field.key}`)])]
|
||||||
const ordered = keys.flatMap (key => {
|
const ordered = keys.flatMap (key => {
|
||||||
const field = fields.find (field => `${prefix}${field.key}` === 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 current = ordered.map (field => `${prefix}${field.key}`)
|
||||||
const next = [...current]
|
const next = [...current]
|
||||||
next.splice (last, 0, ...next.splice (first, 1))
|
next.splice (last, 0, ...next.splice (first, 1))
|
||||||
return [...(params.get ('order') ?? '').split (',').filter (
|
return [...(params.get ('order') ?? '').split (' ').filter (
|
||||||
key => key && !current.includes (key)), ...next].join (',')
|
key => key && !current.includes (key)), ...next].join (' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -220,7 +226,7 @@ export const selectedCountColumns = (
|
|||||||
tag_descriptions: ['language_code', 'body', 'source_language_code'],
|
tag_descriptions: ['language_code', 'body', 'source_language_code'],
|
||||||
tag_groups: ['name', 'description', 'members'],
|
tag_groups: ['name', 'description', 'members'],
|
||||||
materials: ['url', 'source_kind', 'tag_id'] }
|
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]]
|
: [...common, ...defaults[resource]]
|
||||||
return orderedCountFields (fieldsForCounts (resource, definition), resolved)
|
return orderedCountFields (fieldsForCounts (resource, definition), resolved)
|
||||||
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
|
.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> =>
|
export const selectedCountChildren = (params: URLSearchParams): Set<string> => {
|
||||||
new Set (params.has ('children') ? params.get ('children')!.split (',')
|
const selected = new Set (params.has ('children') ? params.get ('children')!.split (' ')
|
||||||
: ['tags.name', 'parents.id', 'aliases.name', 'members.tagId'])
|
: ['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[] =>
|
export const readCountSort = (value: string | null): CountSort[] =>
|
||||||
(value ?? '').split (',').flatMap (entry => {
|
(value ?? '').split (' ').flatMap (entry => {
|
||||||
const [key, direction] = entry.split (':')
|
const [key, direction] = entry.split (':')
|
||||||
return (key && (direction === 'asc' || direction === 'desc'))
|
return (key && (direction === 'asc' || direction === 'desc'))
|
||||||
? [{ key, direction }] : []
|
? [{ key, direction }] : []
|
||||||
@@ -248,7 +267,7 @@ export const readCountSort = (value: string | null): CountSort[] =>
|
|||||||
|
|
||||||
|
|
||||||
export const writeCountSort = (sort: CountSort[]): string =>
|
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 = (
|
export const changeCountSort = (
|
||||||
@@ -274,7 +293,7 @@ export const selectedCountFields = (
|
|||||||
parent = '',
|
parent = '',
|
||||||
): CountField[] => {
|
): CountField[] => {
|
||||||
const selected = params.has (selectionKey)
|
const selected = params.has (selectionKey)
|
||||||
? new Set (params.get (selectionKey)!.split (',')) : null
|
? new Set (params.get (selectionKey)!.split (' ')) : null
|
||||||
return orderedCountFields (fields, params, parent)
|
return orderedCountFields (fields, params, parent)
|
||||||
.filter (field => selected == null || selected.has (field.key))
|
.filter (field => selected == null || selected.has (field.key))
|
||||||
}
|
}
|
||||||
@@ -300,7 +319,7 @@ export const normaliseCountsParams = (
|
|||||||
if (!params.has ('rows') && params.has ('axis'))
|
if (!params.has ('rows') && params.has ('axis'))
|
||||||
{
|
{
|
||||||
const field = params.get ('axis') === 'month' ? 'created_at' : params.get ('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' } : { }) }]))
|
field, ...(field === 'created_at' ? { grain: 'month' } : { }) }]))
|
||||||
}
|
}
|
||||||
if (params.has ('before') && !params.has ('through'))
|
if (params.has ('before') && !params.has ('through'))
|
||||||
@@ -309,16 +328,16 @@ export const normaliseCountsParams = (
|
|||||||
params.delete (key)
|
params.delete (key)
|
||||||
if (params.has ('columns') && ['posts', 'tags'].includes (resource))
|
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))
|
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
|
||||||
.join (','))
|
.join (' '))
|
||||||
}
|
}
|
||||||
if (params.has ('sort'))
|
if (params.has ('sort'))
|
||||||
params.set ('sort', params.get ('sort')!.split (',')
|
params.set ('sort', params.get ('sort')!.split (' ')
|
||||||
.filter (entry => entry.split (':')[0] !== 'original_created_at').join (','))
|
.filter (entry => entry.split (':')[0] !== 'original_created_at').join (' '))
|
||||||
if (params.has ('order'))
|
if (params.has ('order'))
|
||||||
params.set ('order', params.get ('order')!.split (',')
|
params.set ('order', params.get ('order')!.split (' ')
|
||||||
.filter (key => key !== 'original_created_at').join (','))
|
.filter (key => key !== 'original_created_at').join (' '))
|
||||||
const defaults = definition?.defaults?.find (entry =>
|
const defaults = definition?.defaults?.find (entry =>
|
||||||
entry.definition === params.get ('definition') && entry.mode === params.get ('mode'))
|
entry.definition === params.get ('definition') && entry.mode === params.get ('mode'))
|
||||||
for (const { key, value } of defaults?.params ?? [])
|
for (const { key, value } of defaults?.params ?? [])
|
||||||
@@ -327,13 +346,24 @@ export const normaliseCountsParams = (
|
|||||||
if (key === 'sort')
|
if (key === 'sort')
|
||||||
{
|
{
|
||||||
const expected = defaults?.params.find (entry => entry.key === 'rows')?.value
|
const expected = defaults?.params.find (entry => entry.key === 'rows')?.value
|
||||||
const axes = readCountsJSON<CountsAxis[]> (params, 'rows', [])
|
const axes = readCountState<CountsAxis[]> (params, 'rows', [])
|
||||||
const first = expected == null ? null : (JSON.parse (expected) as CountsAxis[])[0]
|
const first = expected == null ? null : readCountState<CountsAxis[]> (new URLSearchParams ({ rows: expected }), 'rows', [])[0]
|
||||||
if (!Array.isArray (axes) || axes[0]?.field !== first?.field
|
if (!Array.isArray (axes) || axes[0]?.field !== first?.field
|
||||||
|| (axes[0]?.grain ?? 'month') !== (first?.grain ?? 'month')) continue
|
|| (axes[0]?.grain ?? 'month') !== (first?.grain ?? 'month')) continue
|
||||||
}
|
}
|
||||||
params.set (key, value)
|
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
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,13 +423,13 @@ export const countsExportPayload = (
|
|||||||
{
|
{
|
||||||
const selected = selectedCountChildren (params)
|
const selected = selectedCountChildren (params)
|
||||||
payload.set ('children', countChildPaths (fieldsForCounts (resource, definition))
|
payload.set ('children', countChildPaths (fieldsForCounts (resource, definition))
|
||||||
.filter (path => selected.has (path)).join (','))
|
.filter (path => selected.has (path)).join (' '))
|
||||||
}
|
}
|
||||||
payload.delete ('current')
|
payload.delete ('current')
|
||||||
payload.delete ('row_numbers')
|
payload.delete ('row_numbers')
|
||||||
if (isCountsState (params) && countsUseLatest (params)) payload.delete ('at')
|
if (isCountsState (params) && countsUseLatest (params)) payload.delete ('at')
|
||||||
return { ...Object.fromEntries (payload), resource, format,
|
return { ...Object.fromEntries (payload), resource, format,
|
||||||
columns: columns.join (','), headers: JSON.stringify (headers),
|
columns: columns.join (' '), headers: JSON.stringify (headers),
|
||||||
labels: JSON.stringify (labels) }
|
labels: JSON.stringify (labels) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,15 +75,3 @@ export const countsAxisFields = (
|
|||||||
history: boolean,
|
history: boolean,
|
||||||
): CountField[] => definition.axes[history ? 'history' : 'latest']
|
): CountField[] => definition.axes[history ? 'history' : 'latest']
|
||||||
.map (key => ({ key, label: countsFieldLabel (key) }))
|
.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,
|
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
|
||||||
sortedCountChildren } from '@/lib/counts'
|
sortedCountChildren } from '@/lib/counts'
|
||||||
import { countsEventTypes } from '@/lib/countsDefinitions'
|
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
|
||||||
import { translateUIMessage } from '@/lib/i18n'
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
import { localisedDateString, msToTime } from '@/lib/utils'
|
import { localisedDateString, msToTime } from '@/lib/utils'
|
||||||
|
|
||||||
import type { CountColumn, CountField, CountRecord,
|
import type { CountColumn, CountField, CountRecord, CountsResult,
|
||||||
CountsResource, CountsVersion } from '@/lib/counts'
|
CountsResource, CountsVersion } from '@/lib/counts'
|
||||||
import type { CountsClipboardCell, CountsClipboardColumn, CountsClipboardTable }
|
import type { CountsClipboardCell, CountsClipboardColumn, CountsClipboardTable }
|
||||||
from '@/lib/countsClipboard'
|
from '@/lib/countsClipboard'
|
||||||
import type { CountsDefinition } from '@/lib/countsDefinitions'
|
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
|
|
||||||
@@ -308,6 +308,7 @@ export const countsExportLabels = (
|
|||||||
for (const event of countsEventTypes)
|
for (const event of countsEventTypes)
|
||||||
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
|
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
|
||||||
labels.subtotal = translateUIMessage ('subtotal', '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.grandTotal = translateUIMessage ('grandTotal', 'counts', locale).text
|
||||||
labels.bot = translateUIMessage ('bot', 'counts', locale).text
|
labels.bot = translateUIMessage ('bot', 'counts', locale).text
|
||||||
labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, {
|
labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, {
|
||||||
@@ -384,3 +385,27 @@ export const countsDetailClipboardTable = (
|
|||||||
return rowStart == null ? cells : [String (rowStart + index), ...cells]
|
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 { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
|
||||||
|
import { queryRecordParams, setQueryRecords } from '@/lib/queryState'
|
||||||
import { switchLocalePath } from '@/lib/locales'
|
import { switchLocalePath } from '@/lib/locales'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
@@ -17,7 +18,7 @@ export const fetchPosts = async (
|
|||||||
...(url && { url }),
|
...(url && { url }),
|
||||||
...(title && { title }),
|
...(title && { title }),
|
||||||
...(tags && { tags }),
|
...(tags && { tags }),
|
||||||
...(tagReferences && { tag_refs: tagReferences }),
|
...(tagReferences && queryRecordParams (new URLSearchParams (tagReferences), 'tag_refs')),
|
||||||
...(match && { match }),
|
...(match && { match }),
|
||||||
...(createdFrom && { created_from: createdFrom }),
|
...(createdFrom && { created_from: createdFrom }),
|
||||||
...(createdTo && { created_to: createdTo }),
|
...(createdTo && { created_to: createdTo }),
|
||||||
@@ -45,14 +46,14 @@ export const switchPostFilterLocale = async (
|
|||||||
negative?: boolean
|
negative?: boolean
|
||||||
missing?: boolean }[] }> ('/posts/tag_filters', {
|
missing?: boolean }[] }> ('/posts/tag_filters', {
|
||||||
locale, params: { tags, target_locale: targetLocale,
|
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 ('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,
|
name: reference.name,
|
||||||
negative: reference.negative,
|
negative: reference.negative,
|
||||||
...(reference.tagId != null && { tag_id: reference.tagId }),
|
...(reference.tagId != null && { tag_id: reference.tagId }),
|
||||||
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
|
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
|
||||||
...(reference.missing && { missing: true }) }))))
|
...(reference.missing && { missing: true }) })))
|
||||||
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
|
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { queryRecordParams } from '@/lib/queryState'
|
||||||
import { QueryClient } from '@tanstack/react-query'
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
import { match } from 'path-to-regexp'
|
import { match } from 'path-to-regexp'
|
||||||
|
|
||||||
@@ -175,8 +176,8 @@ const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
|
|||||||
|
|
||||||
const keys = {
|
const keys = {
|
||||||
tags, match: m, page, limit, url: qURL, title,
|
tags, match: m, page, limit, url: qURL, title,
|
||||||
...(url.searchParams.get ('tag_refs') && {
|
...(url.searchParams.has ('tag_refs') && {
|
||||||
tagReferences: url.searchParams.get ('tag_refs')! }),
|
tagReferences: new URLSearchParams (queryRecordParams (url.searchParams, 'tag_refs')).toString () }),
|
||||||
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
|
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
|
||||||
updatedFrom, updatedTo, order }
|
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": {
|
"columns": {
|
||||||
"changedAt": "操作日時",
|
"changedAt": "操作日時",
|
||||||
"version": "版",
|
"version": "版",
|
||||||
|
"name": "名称",
|
||||||
"resourceId": "Id.",
|
"resourceId": "Id.",
|
||||||
"postTitle": "タイトル",
|
"postTitle": "タイトル",
|
||||||
"postURL": "URL",
|
"postURL": "URL",
|
||||||
@@ -167,7 +168,7 @@
|
|||||||
"changedField": "変更項目",
|
"changedField": "変更項目",
|
||||||
"eventType": "異動区分",
|
"eventType": "異動区分",
|
||||||
"count": "件数",
|
"count": "件数",
|
||||||
"recordTotal": "総件数:{{count}}",
|
"recordTotal": "閲覧可能件数:{{count}}",
|
||||||
"grain": "粒度",
|
"grain": "粒度",
|
||||||
"grains": {
|
"grains": {
|
||||||
"year": "年",
|
"year": "年",
|
||||||
@@ -235,6 +236,7 @@
|
|||||||
},
|
},
|
||||||
"rowNumberHeading": "No.",
|
"rowNumberHeading": "No.",
|
||||||
"subtotal": "小計",
|
"subtotal": "小計",
|
||||||
|
"singleTotal": "合計",
|
||||||
"grandTotal": "綜合計",
|
"grandTotal": "綜合計",
|
||||||
"rowNumbers": "行番号",
|
"rowNumbers": "行番号",
|
||||||
"periodSeparator": "〜",
|
"periodSeparator": "〜",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { countsRequestParams, defaultCountsMode, countsExportPayload, fieldsForC
|
|||||||
import { countsExportLabels } from '@/lib/countsDisplay'
|
import { countsExportLabels } from '@/lib/countsDisplay'
|
||||||
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
||||||
countsResources, countsFieldLabel,
|
countsResources, countsFieldLabel,
|
||||||
fetchCountsDefinition, readCountsJSON, useCountsDefinition }
|
fetchCountsDefinition, useCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import { countsKeys } from '@/lib/queryKeys'
|
import { countsKeys } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -29,6 +29,8 @@ import useDialogue from '@/lib/dialogues/useDialogue'
|
|||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import { setCountState, readCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
import type { CountsResource } from '@/lib/counts'
|
import type { CountsResource } from '@/lib/counts'
|
||||||
@@ -44,7 +46,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
controlParams.delete ('order')
|
controlParams.delete ('order')
|
||||||
controlParams.delete ('row_numbers')
|
controlParams.delete ('row_numbers')
|
||||||
controlParams.set ('columns', selectedCountColumns (resource, params, definition)
|
controlParams.set ('columns', selectedCountColumns (resource, params, definition)
|
||||||
.filter (column => column !== 'thumbnail').sort ().join (','))
|
.filter (column => column !== 'thumbnail').sort ().join (' '))
|
||||||
const request = countsRequestParams (params)
|
const request = countsRequestParams (params)
|
||||||
const [open, setOpen] = useState (false)
|
const [open, setOpen] = useState (false)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState (false)
|
const [sidebarOpen, setSidebarOpen] = useState (false)
|
||||||
@@ -99,10 +101,17 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
}, [canonical, data, isPlaceholderData, params, setParams])
|
}, [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)
|
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)
|
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')
|
if (key === 'sort') next.delete ('page')
|
||||||
setParams (next)
|
setParams (next)
|
||||||
}
|
}
|
||||||
@@ -113,8 +122,8 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
setExportError (false)
|
setExportError (false)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const axes = [...readCountsJSON<CountsAxis[]> (params, 'rows', []),
|
const axes = [...readCountState<CountsAxis[]> (params, 'rows', []),
|
||||||
...readCountsJSON<CountsAxis[]> (params, 'cols', [])]
|
...readCountState<CountsAxis[]> (params, 'cols', [])]
|
||||||
const fields = mode === 'summary' ? [
|
const fields = mode === 'summary' ? [
|
||||||
...axes.map (axis => ({
|
...axes.map (axis => ({
|
||||||
key: axis.field, label: countsFieldLabel (axis.field) })),
|
key: axis.field, label: countsFieldLabel (axis.field) })),
|
||||||
@@ -212,7 +221,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
displayThumbnail={enabled => {
|
displayThumbnail={enabled => {
|
||||||
const columns = selectedCountColumns (resource, params, definition)
|
const columns = selectedCountColumns (resource, params, definition)
|
||||||
.filter (column => column !== 'thumbnail')
|
.filter (column => column !== 'thumbnail')
|
||||||
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (','))
|
update ('columns', (enabled ? ['thumbnail', ...columns] : columns).join (' '))
|
||||||
}}
|
}}
|
||||||
execute={next => {
|
execute={next => {
|
||||||
if (next.toString () === canonical) void refetch ()
|
if (next.toString () === canonical) void refetch ()
|
||||||
@@ -231,7 +240,11 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
|||||||
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
||||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
{data != null && <>
|
{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}
|
{mode === 'summary' ? <CountsSummaryResults resource={resource}
|
||||||
data={data} params={params}
|
data={data} params={params}
|
||||||
pending={isPlaceholderData} update={update}/> : (
|
pending={isPlaceholderData} update={update}/> : (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { queryRecordParams } from '@/lib/queryState'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
@@ -61,7 +62,7 @@ const PostListPage: FC = () => {
|
|||||||
|
|
||||||
const keys = {
|
const keys = {
|
||||||
tags: tagsKey, match, page, limit,
|
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: '',
|
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
|
||||||
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
|
||||||
order } as const
|
order } as const
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { queryRecordParams } from '@/lib/queryState'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
@@ -126,7 +127,7 @@ const PostSearchPage: FC = () => {
|
|||||||
|
|
||||||
const keys: FetchPostsParams = {
|
const keys: FetchPostsParams = {
|
||||||
tags: qTags, match: qMatch, page, limit,
|
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,
|
url: qURL,
|
||||||
title: qTitle,
|
title: qTitle,
|
||||||
originalCreatedFrom: qOriginalCreatedFrom,
|
originalCreatedFrom: qOriginalCreatedFrom,
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする