コミットを比較

..
43 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 7afb96d226 #428 2026-10-20 20:29:18 +09:00
みてるぞ 4cbcf63576 #428 2026-10-20 12:38:36 +09:00
みてるぞ e8d4c5ad09 #428 2026-10-20 02:42:14 +09:00
みてるぞ 8d2f18e8fc #428 2026-10-20 01:34:25 +09:00
みてるぞ 83563e36ef #428 2026-10-19 23:43:28 +09:00
みてるぞ 4ab13c4244 #428 2026-10-19 23:08:59 +09:00
みてるぞ d365406019 #428 2026-10-19 20:56:07 +09:00
みてるぞ 4e656bad55 #428 2026-10-19 12:46:57 +09:00
みてるぞ 0622d5ba0b #428 2026-10-19 12:30:59 +09:00
みてるぞ 716428b047 #428 2026-10-19 08:42:23 +09:00
みてるぞ aa11d09fc3 #428 2026-10-19 00:46:03 +09:00
みてるぞ d5046a17c9 #428 2026-10-19 00:28:16 +09:00
みてるぞ a86129006d #428 2026-10-19 00:03:55 +09:00
みてるぞ 112d75c3bb #428 2026-10-18 23:31:17 +09:00
みてるぞ 8bcc61c0f8 #428 2026-10-18 23:26:55 +09:00
みてるぞ e0970a81a4 #428 2026-10-18 22:14:40 +09:00
みてるぞ 22f4a0af40 #428 2026-10-18 19:03:02 +09:00
みてるぞ 912b941f90 #428 2026-10-18 18:54:59 +09:00
みてるぞ 569b9234c6 #428 2026-10-18 18:28:05 +09:00
みてるぞ e187331af4 #428 2026-10-18 17:41:37 +09:00
みてるぞ b3b00e170f #428 2026-10-18 17:05:36 +09:00
みてるぞ c9628a120d #428 2026-10-18 16:03:30 +09:00
みてるぞ 525f6883c9 #428 2026-10-18 15:50:53 +09:00
みてるぞ 20085d5017 #428 2026-10-18 15:36:09 +09:00
みてるぞ bbee83e35b #428 2026-10-18 13:56:03 +09:00
みてるぞ 1dcb790f44 #428 2026-10-18 12:57:09 +09:00
みてるぞ b3c42b8eb3 #428 2026-10-18 12:32:30 +09:00
みてるぞ a7e350d28e #428 2026-10-18 12:18:52 +09:00
みてるぞ d9bfac8392 #428 2026-10-18 03:13:31 +09:00
みてるぞ 4c1178dab5 #428 2026-10-18 03:06:53 +09:00
みてるぞ 2d1a8b4730 #428 2026-10-18 02:45:26 +09:00
みてるぞ 61d7e6f44e #428 2026-10-18 02:31:03 +09:00
みてるぞ 753a150128 #428 2026-10-18 02:22:00 +09:00
みてるぞ cd948611af #428 2026-10-18 01:59:59 +09:00
みてるぞ 34a4ffefb4 #428 2026-10-18 01:52:09 +09:00
みてるぞ eda81cfa89 #428 2026-10-18 01:39:03 +09:00
みてるぞ 0c1f459d4e #428 2026-10-18 01:09:31 +09:00
みてるぞ 9be0c7b1d9 #428 2026-10-18 00:20:30 +09:00
みてるぞ 63860c94af #428 2026-10-18 00:07:07 +09:00
みてるぞ 851d5e8e09 #428 2026-10-17 23:49:20 +09:00
みてるぞ 91296f7477 #428 2026-10-17 23:01:24 +09:00
みてるぞ f063fc54bb #428 2026-10-17 19:59:18 +09:00
みてるぞ b945ceef60 #428 2026-10-17 16:47:08 +09:00
105個のファイルの変更、230行の追加、8878行の削除
+4 -30
ファイルの表示
@@ -1,18 +1,5 @@
# AGENTS.md
## Mandatory security and URL contracts
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
## Project overview
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
@@ -586,10 +573,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- Do not invent replacement copy when removing unrequested wording.
- 明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
fixture、mock、snapshot、テスト補助コードも対象とする。
既存のテスト差分を勝手に修正・差戻ししない。
この規約はディレクトリ固有の自動的なテスト実行推奨より優先する。
- Do not create, modify, or run tests unless the user explicitly asks for
test work. When the user asks for tests, keep working within the permitted
test-file scope and rerun them until they pass or the remaining failure is
clearly blocked.
- Test-only work includes adding, updating, deleting, reorganising, or fixing
SyntaxError in tests. During test-only work, do not modify production code.
- During test-only work, do not change production constants, behaviour, API
@@ -644,19 +631,6 @@ and layout reuse, follow `frontend/AGENTS.md`.
## Frontend rules
- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。
新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。
不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
`_untranslated` 規約を使い、language metadata を維持する。
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
SSR / hydration の整合及び翻訳キーの非露出を検証する。
i18n の検証を理由にテスト関連作業を開始しない。
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
アクセシビリティ用ラベルも UI 文言として扱う。
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays.
-15
ファイルの表示
@@ -1,20 +1,5 @@
# backend/AGENTS.md
## Mandatory security and URL contracts
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。
## Scope
These rules apply to work under `backend/`.
+22 -22
ファイルの表示
@@ -1,5 +1,4 @@
class ApplicationController < ActionController::API
include RequestAccess
include ActionController::Cookies
include ActionController::RequestForgeryProtection
@@ -9,46 +8,34 @@ class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
rescue_from(ActiveRecord::RecordNotFound) { head :not_found }
rescue_from(UnsupportedLocale) { head :not_found }
before_action :reject_banned_ip_address!
before_action :authenticate_user
before_action :reject_banned_user!
before_action :validate_explicit_locale!
before_action :enforce_query_contract!
around_action :with_history_authority
rescue_from(HistoryAccess::Forbidden) { head :not_found }
protect_from_forgery with: :exception
self.forgery_protection_origin_check = true
rescue_from ActionController::InvalidAuthenticityToken, with: :reject_invalid_csrf
after_action :prevent_authenticated_caching
def current_user = @current_user
private
def enforce_query_contract!
pairs = URI.decode_www_form(request.query_string)
return head :bad_request if pairs.length > 1000 || pairs.map(&:first).uniq.length != pairs.length
return head :bad_request if request.query_parameters.values.any? { !_1.is_a?(String) }
if (controller_name.start_with?('counts_') &&
(request.query_parameters.keys & ['headers', 'labels']).any?)
return head :bad_request
end
rescue ArgumentError
head :bad_request
end
def with_history_authority(&action)
response.headers['Cache-Control'] = 'private, no-store'
HistoryAccess.set(user: current_user, &action)
end
def reject_invalid_csrf
response.headers['X-CSRF-Invalid'] = '1'
response.headers['Cache-Control'] = 'private, no-store'
head :forbidden
end
def authenticate_user
code = session[:inheritance_code]
return if code.blank?
@current_user = User.find_by(inheritance_code: code)
end
def prevent_authenticated_caching
response.headers['Cache-Control'] = 'private, no-store' if current_user
end
@@ -92,6 +79,19 @@ class ApplicationController < ActionController::API
render_validation_error base: ['すでに存在してゐます.']
end
def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned?
head :forbidden
end
def reject_banned_user!
return unless current_user&.banned?
head :forbidden
end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
errors = { }
-21
ファイルの表示
@@ -1,21 +0,0 @@
module CountsListing
extend ActiveSupport::Concern
def index
locale = resolve_locale!
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
.merge('locale' => locale.code))
result = { population_count_scope: 'all_recorded_versions', population_count: HistoryAccess.population_count(query.relation.klass), count: query.count, page: query.page, limit: query.limit,
at: request.query_parameters['at'].presence || Time.current.utc.iso8601(6) }
if query.mode == 'summary'
result.merge!(query.summary)
else
result[:versions] = CountsVersionRepr.many(
query.details, resource:, language_code: locale.language_code)
end
render json: result
rescue ArgumentError, TypeError
head :bad_request
end
end
-52
ファイルの表示
@@ -1,52 +0,0 @@
# Permanent storage URLs must not bypass a resource's later discard boundary.
module HistoryStorageAccess
extend ActiveSupport::Concern
included do
include RequestAccess
prepend_before_action :authorise_history_storage!
end
private
def authorise_history_storage!
response.headers['Cache-Control'] = 'private, no-store'
return unless request.get? || request.head?
reject_banned_ip_address!
return if performed?
authenticate_user
reject_banned_user!
return if performed?
signed = params[:signed_blob_id] || params[:signed_id]
blob = if signed
ActiveStorage::Blob.find_signed(signed)
elsif params[:encoded_key]
payload = ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
key = payload && (payload[:key] || payload['key'])
if key
original = key.start_with?('variants/') ? key.split('/')[1] : key
ActiveStorage::Blob.find_by(key: original)
end
end
return head :not_found unless blob
allowed = HistoryAccess.set(user: current_user) { HistoryAccess.blob_visible?(blob) }
head :not_found unless allowed
rescue ActiveSupport::MessageVerifier::InvalidSignature
head :not_found
end
# Override the engine's public-forever caching before a streaming response commits.
def http_cache_forever(**_options)
response.headers['Cache-Control'] = 'private, no-store'
yield
end
def expires_in(*arguments, **options)
super
response.headers['Cache-Control'] = 'private, no-store'
end
end
-28
ファイルの表示
@@ -1,28 +0,0 @@
# 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
-83
ファイルの表示
@@ -1,83 +0,0 @@
class CountsExportsController < ApplicationController
wrap_parameters false
def create_job
locale = resolve_locale!
payload = request.request_parameters.merge(request.query_parameters)
session[:counts_export_owner] ||= SecureRandom.hex(32)
job = CountsExportJobs.start(payload, session[:counts_export_owner], locale.language_code)
response.headers['Cache-Control'] = 'private, no-store'
render json: { job: }, status: :accepted
rescue CountsExportDownload::Busy
head :too_many_requests
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
def job_status
job_state(cancel: false)
end
def cancel_job
job_state(cancel: true)
end
def prepare
locale = resolve_locale!
payload = request.request_parameters.merge(request.query_parameters)
exporter = CountsExporter.new(payload, language_code: locale.language_code)
session[:counts_export_owner] ||= SecureRandom.hex(32)
token = CountsExportDownload.prepare(exporter, session[:counts_export_owner])
response.headers['Cache-Control'] = 'private, no-store'
render json: { token: }
rescue CountsExportDownload::Busy
head :too_many_requests
rescue CountsExportDownload::TooLarge
head 413
rescue CountsExportDownload::TimedOut
head :gateway_timeout
rescue CountsExportDownload::StorageFull, Errno::ENOSPC
head :insufficient_storage
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
def download
file = CountsExportDownload.find(params[:token], session[:counts_export_owner])
return head :not_found unless file
response.headers['Cache-Control'] = 'private, no-store'
send_file file.fetch('path'),
filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
type: file['format'] == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
end
def job_state(cancel:)
result = CountsExportJobs.status(params[:token], session[:counts_export_owner], cancel:)
return head :not_found unless result
response.headers['Cache-Control'] = 'private, no-store'
render json: result
end
private :job_state
def index
locale = resolve_locale!
payload = request.request_parameters.except('authenticity_token')
.merge(request.query_parameters)
exporter = CountsExporter.new(payload,
language_code: locale.language_code)
session[:counts_export_owner] ||= SecureRandom.hex(32)
token = CountsExportDownload.prepare(exporter, session[:counts_export_owner])
file = CountsExportDownload.find(token, session[:counts_export_owner])
return head :not_found unless file
send_file file.fetch('path'),
filename: "counts-#{ exporter.resource }.#{ exporter.format }",
type: exporter.format == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
end
-3
ファイルの表示
@@ -1,3 +0,0 @@
class CountsPostsController < PostVersionsController
include CountsListing
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
class CountsStatesController < ApplicationController
def index
locale = resolve_locale!
result = CountsStateQuery.new(request.query_parameters,
language_code: locale.language_code).call
render json: result
rescue ArgumentError, TypeError
head :bad_request
end
end
-23
ファイルの表示
@@ -1,23 +0,0 @@
class CountsTablesController < ApplicationController
def definition
resolve_locale!
definition = CountsResourceDefinition.new(params[:resource])
render json: { fields: definition.metadata,
output_fields: definition.output_fields,
filter_fields: definition.filter_metadata,
axes: { latest: definition.axes(false), history: definition.axes(true) },
grains: CountsAxes::GRAINS, defaults: CountsDefaults.metadata(params[:resource]) }
rescue ArgumentError
head :bad_request
end
def index
resolve_locale!
render json: CountsTableQuery.new(request.query_parameters).call
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
end
-3
ファイルの表示
@@ -1,3 +0,0 @@
class CountsTagsController < TagVersionsController
include CountsListing
end
+2 -2
ファイルの表示
@@ -8,7 +8,7 @@ class MaterialVersionsController < ApplicationController
offset = (page - 1) * limit
q = HistoryAccess.visible(MaterialVersion.includes(:created_by_user))
q = MaterialVersion.includes(:created_by_user)
q = q.where(material_id: params[:material_id]) if params[:material_id].present?
q = q.where(tag_name: params[:tag].to_s.strip) if params[:tag].present?
q = q.where(event_type: params[:event_type]) if params[:event_type].present?
@@ -20,7 +20,7 @@ class MaterialVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions), count:, population_count: HistoryAccess.population_count(MaterialVersion) }
render json: { versions: serialise_versions(versions), count: }
end
private
+17 -15
ファイルの表示
@@ -14,7 +14,21 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit
q = HistoryAccess.visible(version_scope)
q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
@@ -32,23 +46,11 @@ class PostVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(PostVersion) }
render json: { versions: serialise_versions(versions, locale.language_code), count: }
end
private
def version_scope
PostVersion.with_previous_version
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
end
def serialise_versions rows, language_code
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq
@@ -101,7 +103,7 @@ class PostVersionsController < ApplicationController
}
missing_ids = ids - names.keys
HistoryAccess.visible(NicoTagVersion.all)
NicoTagVersion
.where(tag_id: missing_ids)
.order(:tag_id, version_no: :desc)
.pluck(:tag_id, :name)
+55 -9
ファイルの表示
@@ -38,7 +38,7 @@ class PostsController < ApplicationController
offset = (page - 1) * limit
pt_max_sql =
HistoryAccess.visible(PostVersion.all)
PostVersion
.select('post_id, MAX(created_at) AS max_updated_at')
.group('post_id')
.to_sql
@@ -344,7 +344,6 @@ class PostsController < ApplicationController
unless force
base_version = post.post_versions.find_by!(version_no: base_version_no)
HistoryAccess.require!(base_version)
base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post, locale)
end
@@ -450,8 +449,24 @@ class PostsController < ApplicationController
private
def tag_filter_references literals
PostTagSearch.parse_references(params.to_unsafe_h, literals)
rescue ArgumentError, JSON::ParserError, TypeError
raw = params[:tag_refs]
return nil if raw.blank?
references = JSON.parse(raw)
valid = references.is_a?(Array) && references.all? do |reference|
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
ids = [reference['tag_id'], reference['external_tag_id']].compact
((ids.any? || reference['missing'] == true) &&
ids.all? { _1.is_a?(Integer) && _1.positive? } &&
(!reference.key?('missing') || (reference['missing'] == true && ids.empty?)) &&
(!reference.key?('negative') || [true, false].include?(reference['negative'])))
end
raise ActionController::BadRequest unless valid
return nil unless references.map { _1['name'] } == literals
references
rescue JSON::ParserError, TypeError
raise ActionController::BadRequest
end
@@ -467,7 +482,17 @@ class PostsController < ApplicationController
def filter_posts_by_tags locale, tag_names, match_type
references = tag_filter_references(tag_names)
literals = PostTagSearch.literals(locale, tag_names.join(' '), references:)
literals = tag_names.each_with_index.map do |raw_name, index|
name = raw_name.sub(/\Anot:/i, '')
name = TagName.canonicalise(locale, name).first unless references
reference = references&.[](index)
negative = if reference&.key?('negative')
reference['negative']
else
raw_name.downcase.start_with?('not:')
end
{ name:, negative:, reference: }
end
return Post.all if literals.empty?
@@ -498,10 +523,31 @@ class PostsController < ApplicationController
end
def tagged_post_ids_for locale, name, reference: nil
internal, external = PostTagSearch.references(locale, { name:, reference: })
Post.where(id: PostTag.where(tag_id: Array(internal)).select(:post_id))
.or(Post.where(id: PostExternalTag.where(external_tag_id: Array(external))
.select(:post_id))).select(:id)
if reference
matches = Post.none
if reference['tag_id']
matches = matches.or(Post.where(id: PostTag.where(
tag_id: reference['tag_id']).select(:post_id)))
end
if reference['external_tag_id']
matches = matches.or(Post.where(id: PostExternalTag.where(
external_tag_id: reference['external_tag_id']).select(:post_id)))
end
return matches.select(:id)
end
posts_by_internal_tags =
Post
.joins(tags: :tag_names)
.where(tag_names: { language_code: locale.language_code, name: })
.select(:id)
posts_by_external_tags =
Post
.joins(:external_tags)
.where("CONCAT(external_tags.platform, ':', external_tags.name) = ?", name)
(posts_by_internal_tags + posts_by_external_tags).uniq(&:id)
end
def sync_post_tags! post, desired_tags, sections
+1 -2
ファイルの表示
@@ -42,7 +42,7 @@ class SitemapEntriesController < ApplicationController
when 'descriptions'
TagDescription.where(language_code: locale.language_code, tag_id: named_tags)
.where.not(body: [nil, ''])
.where(HistoryAccess.visible(TagDescriptionVersion.all)
.where(TagDescriptionVersion
.where('tag_description_versions.tag_id = tag_descriptions.tag_id')
.where('tag_description_versions.language_code = tag_descriptions.language_code')
.where('tag_description_versions.version_no = tag_descriptions.version_no')
@@ -50,7 +50,6 @@ class SitemapEntriesController < ApplicationController
when 'tag_groups'
TagGroup.joins(:versions)
.where('tag_group_versions.version_no = tag_groups.version_no')
.where(Arel.sql(HistoryAccess.sql(TagGroupVersion)))
when 'theatres'
Theatre.kept
end
-1
ファイルの表示
@@ -49,7 +49,6 @@ class TagDescriptionsController < ApplicationController
private
def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion)
description.as_json(only: [
:tag_id,
:language_code,
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class TagGroupsController < ApplicationController
def index
locale = resolve_locale!
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a)
end
+12 -17
ファイルの表示
@@ -10,7 +10,17 @@ class TagVersionsController < ApplicationController
offset = (page - 1) * limit
q = HistoryAccess.visible(version_scope)
q = TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
count = q.except(:select, :order, :limit, :offset).count
@@ -19,26 +29,11 @@ class TagVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions, locale.language_code), count:, population_count: HistoryAccess.population_count(TagVersion) }
render json: { versions: serialise_versions(versions, locale.language_code), count: }
end
private
def version_scope
TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
AND #{ HistoryAccess.sql(TagVersion, 'prev') }
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
end
def serialise_versions rows, language_code
rows = rows.to_a
tag_ids = rows.map(&:tag_id).uniq
+14 -14
ファイルの表示
@@ -8,12 +8,12 @@ class WikiPagesController < ApplicationController
title = params[:title].to_s.strip
descriptions =
HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
TagDescription
.where(language_code: locale.language_code)
.includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions =
@@ -41,13 +41,13 @@ class WikiPagesController < ApplicationController
return head :not_found unless language.is_a?(String)
return head :not_found unless Language.exists?(code: language)
names = HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
names = TagDescription.joins(tag: :tag_names)
.where(language_code: locale.language_code, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(locale, tag_id:, description_id:, name:, language:)
end
candidates.concat(HistoryAccess.current(TagGroup.all, TagGroupVersion).order(:id).pluck(:id, :name).map do |id, name|
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(locale, id:, name:, language:)
end)
render json: candidates
@@ -286,12 +286,12 @@ class WikiPagesController < ApplicationController
tag_group = id && TagGroup.find_by(id:)
if tag_group
return render json: tag_group_changes(HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: tag_group.id))
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
end
json = []
unless id
json.concat(tag_group_changes(HistoryAccess.visible(TagGroupVersion.all)))
json.concat(tag_group_changes(TagGroupVersion.all))
json.concat(description_changes(locale,
TagDescription.where(language_code: locale.language_code)))
json = json.each_with_index.sort_by do |row, index|
@@ -327,7 +327,7 @@ class WikiPagesController < ApplicationController
def render_group_representation representation, version, body: nil, status: :ok
body ||= TagGroupMarkdownRenderer.render(representation)
versions = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: version.tag_group_id)
versions = TagGroupVersion.where(tag_group_id: version.tag_group_id)
pred = versions.find_by(version_no: version.version_no - 1)&.id
succ = versions.find_by(version_no: version.version_no + 1)&.id
updated_at = version.created_at
@@ -346,11 +346,11 @@ class WikiPagesController < ApplicationController
end
def current_group_version group
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def group_version group, version_id
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, id: version_id)
TagGroupVersion.find_by(tag_group_id: group.id, id: version_id)
end
def wiki_diff from_body, to_body
@@ -373,7 +373,7 @@ class WikiPagesController < ApplicationController
def tag_group_changes versions
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id)
predecessors = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: groups.keys,
predecessors = TagGroupVersion.where(tag_group_id: groups.keys,
version_no: rows.map { _1.version_no - 1 })
.pluck(:tag_group_id, :version_no, :id)
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
@@ -406,18 +406,18 @@ class WikiPagesController < ApplicationController
end
def description_versions description
HistoryAccess.visible(TagDescriptionVersion.all).where(tag_id: description.tag_id,
TagDescriptionVersion.where(tag_id: description.tag_id,
language_code: description.language_code)
end
def tag_description_by_title locale, title
HistoryAccess.current(TagDescription.all, TagDescriptionVersion).joins(tag: :tag_names)
TagDescription.joins(tag: :tag_names)
.find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title })
end
def description_changes locale, descriptions
versions = HistoryAccess.visible(TagDescriptionVersion.all).where(language_code: locale.language_code,
versions = TagDescriptionVersion.where(language_code: locale.language_code,
tag_id: descriptions.select(:tag_id))
.order(id: :desc).limit(200).to_a
return [] if versions.empty?
@@ -425,7 +425,7 @@ class WikiPagesController < ApplicationController
by_tag = descriptions.where(tag_id: versions.map(&:tag_id))
.includes(tag: :tag_names).index_by(&:tag_id)
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
predecessors = HistoryAccess.visible(TagDescriptionVersion.all).where(
predecessors = TagDescriptionVersion.where(
language_code: locale.language_code, tag_id: by_tag.keys,
version_no: versions.map { |version| version.version_no - 1 })
.pluck(:tag_id, :version_no, :id)
-160
ファイルの表示
@@ -1,160 +0,0 @@
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
# Only the resource-wide denied count is public, never a condition-dependent denied count.
def self.population_counts(model)
total, excluded = model.unscoped.optimizer_hints('MAX_EXECUTION_TIME(10000)').pick(
Arel.sql('COUNT(*)'),
Arel.sql("COALESCE(SUM(CASE WHEN #{ sql(model) } THEN 0 ELSE 1 END), 0)"))
{ population_count: total.to_i, excluded_count: excluded.to_i,
excluded_count_scope: 'all_recorded_versions' }
end
end
-6
ファイルの表示
@@ -1,12 +1,6 @@
class PostVersion < ApplicationRecord
include VersionRecord
scope :with_previous_version, -> {
joins('LEFT JOIN post_versions prev ON prev.post_id = post_versions.post_id ' +
'AND prev.version_no = post_versions.version_no - 1 AND ' +
HistoryAccess.sql(PostVersion, 'prev'))
}
belongs_to :post
belongs_to :parent, class_name: 'Post', optional: true
-2
ファイルの表示
@@ -1,5 +1,3 @@
class TagDescriptionVersion < ApplicationRecord
include VersionRecord
before_create { SharedResourceIdAllocator.assign!(self) }
end
-2
ファイルの表示
@@ -6,8 +6,6 @@ module VersionRecord
discard: 'discard',
restore: 'restore' }.freeze
def require_history_access! = HistoryAccess.require!(self)
def readonly? = persisted?
included do
-135
ファイルの表示
@@ -1,135 +0,0 @@
# Counts uses recorded values only; history/diff endpoints retain their own contract.
class CountsVersionRepr
def self.many(rows, resource:, language_code:, at: nil, inclusive: true)
new(rows.to_a, resource:, language_code:, at:, inclusive:).serialise
end
def initialize(rows, resource:, language_code:, at:, inclusive:)
@rows = rows
HistoryAccess.require_many!(rows)
@at = at
@inclusive = inclusive
@resource = resource
@language = language_code
@users = User.where(id: rows.map(&:created_by_user_id).compact).pluck(:id, :name).to_h
@languages = master_names(Language)
@scripts = master_names(Script)
@tag_versions = {}
load_tag_versions if resource == 'posts'
load_parent_versions if resource == 'tags'
end
def serialise
@rows.map do |row|
resource_id = row.public_send(@resource == 'posts' ? :post_id : :tag_id)
common = { id: row.id, resource_id:,
version_no: row.version_no, is_latest: row.attributes['counts_latest'].to_i == 1,
created_at: row.created_at.iso8601,
created_by_user: row.created_by_user_id &&
{ id: row.created_by_user_id, name: @users[row.created_by_user_id] } }
common.merge(@resource == 'posts' ? post(row) : tag(row))
end
end
private
def master_names(model)
model.select('code AS counts_code', :name).to_h do |record|
[record.attributes['counts_code'], record.name]
end
end
def json(value)
value.is_a?(String) ? JSON.parse(value) : value
end
def load_tag_versions
pairs = @rows.flat_map { Array(json(_1.tags_json)) }
.filter_map { |entry| [entry['tag_id'], entry['version_no']] if entry['tag_id'] }
.uniq
pairs.each_slice(500) do |batch|
placeholders = batch.map { '(?, ?)' }.join(', ')
scope = HistoryAccess.visible(TagVersion.all)
if @at
scope = scope.where("created_at #{ @inclusive ? '<=' : '<' } ?", @at)
end
scope.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten])
.select(:tag_id, :version_no, :name, :category, :names).each do |version|
@tag_versions[[version.tag_id, version.version_no]] = version
end
end
end
def names(value)
(json(value) || {}).sort.map do |language, entry|
aliases = entry.fetch('aliases', []).map do |alias_entry|
alias_entry.merge('script_name' => @scripts[alias_entry['script_code']])
end
{ language_code: language, language_name: @languages[language],
script_code: entry['script_code'], script_name: @scripts[entry['script_code']],
name: entry['name'], auto_generated: entry['auto_generated'], aliases: }
end
end
def post(row)
snapshots = Array(json(row.tags_json)).map do |entry|
version = @tag_versions[[entry['tag_id'], entry['version_no']]]
{ id: entry['tag_id'] || entry['external_tag_id'],
kind: entry['tag_id'] ? 'internal' : 'external',
tag_id: entry['tag_id'], external_tag_id: entry['external_tag_id'],
version_no: entry['version_no'], name: entry['name'] || version&.name,
category: entry['category'] || version&.category,
sections: entry.fetch('sections', []), names: version ? names(version.names) : [] }
end
{ title: row.title, url: row.url, thumbnail_base: row.thumbnail_base,
tags: snapshots, parents: row.parent_post_ids.to_s.split.map { { id: _1.to_i } },
video_ms: row.video_ms,
original_created_from: row.original_created_from&.iso8601,
original_created_before: row.original_created_before&.iso8601 }
end
def parent_point(row) = @at || row.created_at
def load_parent_versions
@parent_versions = {}
requests = @rows.flat_map do |row|
row.parent_tag_ids.to_s.split.map { [_1.to_i, parent_point(row)] }
end.uniq
requests.each_slice(100) do |batch|
queries = batch.map do |id, point|
scope = HistoryAccess.visible(TagVersion.where(tag_id: id))
.where('created_at <= ?', point)
.where('NOT EXISTS (SELECT 1 FROM tag_versions newer ' +
'WHERE newer.tag_id = tag_versions.tag_id AND newer.created_at <= ? ' +
'AND newer.version_no > tag_versions.version_no)', point)
quoted = TagVersion.connection.quote(point.iso8601(6))
scope.select("tag_id, name, names, #{ quoted } AS reference_point").to_sql
end
next if queries.empty?
TagVersion.connection.select_all(queries.map { "(#{ _1 })" }.join(' UNION ALL ')).each do |value|
@parent_versions[[value['tag_id'], value['reference_point']]] = value
end
end
end
def tag(row)
recorded_names = names(row.names)
selected = recorded_names.find { _1[:language_code] == @language }
aliases = recorded_names.flat_map do |entry|
entry[:aliases].map {
_1.merge('language_code' => entry[:language_code],
'language_name' => entry[:language_name])
}
end
{ name: selected&.fetch(:name) || row.name, names: recorded_names,
category: row.category, aliases:,
parents: row.parent_tag_ids.to_s.split.map do |id|
value = @parent_versions[[id.to_i, parent_point(row).iso8601(6)]]
snapshots = value && json(value['names'])
name = snapshots&.dig(@language, 'name') || value&.fetch('name')
{ id: id.to_i, name: }
end,
deprecated: row.deprecated_at&.iso8601 }
end
end
+3 -5
ファイルの表示
@@ -44,7 +44,6 @@ module TagGroupRepr
end
def one locale, group
HistoryAccess.require_current!(group, TagGroupVersion)
many(locale, [group]).first
end
@@ -57,11 +56,10 @@ module TagGroupRepr
end
def current_version group
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def historical locale, version
HistoryAccess.require!(version)
members = Array(version.members)
placements = Array(version.placements)
tag_references = members.filter_map { |member| member['tag_id'] } +
@@ -155,13 +153,13 @@ module TagGroupRepr
end
def historical_tag_versions ids
HistoryAccess.visible(TagVersion.all).where(tag_id: ids.uniq).to_a.index_by do |version|
TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version|
[version.tag_id, version.version_no]
end
end
def historical_group_versions ids
HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: ids.uniq).to_a.index_by do |version|
TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version|
[version.tag_group_id, version.version_no]
end
end
-1
ファイルの表示
@@ -5,7 +5,6 @@ module WikiPageRepr
module_function
def tag_description locale, description
HistoryAccess.require_current!(description, TagDescriptionVersion)
tag = description.tag
{ 'id' => description.id,
-100
ファイルの表示
@@ -1,100 +0,0 @@
class CountsAxes
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
def self.time_expression(expression, grain, timezone)
raise ArgumentError unless GRAINS.include?(grain)
connection = ApplicationRecord.connection
# Use the installed TZInfo database, including DST, rather than MySQL timezone tables.
first = Time.utc(1900)
last = Time.utc(2101)
offset = timezone.period_for_utc(first).utc_total_offset
clauses = timezone.transitions_up_to(last, first).filter_map do |transition|
next if transition.offset.utc_total_offset == offset
previous = offset
offset = transition.offset.utc_total_offset
"WHEN #{ expression } < #{ connection.quote(transition.at.to_time) } THEN #{ previous }"
end
seconds = clauses.empty? ? offset.to_s : "CASE #{ clauses.join(' ') } ELSE #{ offset } END"
local = "DATE_ADD(#{ expression }, INTERVAL (#{ seconds }) SECOND)"
case grain
when 'year' then "DATE_FORMAT(#{ local }, '%Y')"
when 'quarter' then "CONCAT(YEAR(#{ local }), '-Q', QUARTER(#{ local }))"
when 'month' then "DATE_FORMAT(#{ local }, '%Y-%m')"
when 'week' then "DATE_FORMAT(#{ local }, '%x-W%v')"
when 'day' then "DATE_FORMAT(#{ local }, '%Y-%m-%d')"
else "DATE_FORMAT(#{ local }, '%Y-%m-%d %H:00')"
end
end
attr_reader :rows, :columns, :all
def initialize(population)
@population = population
@definition = population.definition
@rows = parse(population.params.fetch('rows', ''))
@columns = parse(population.params.fetch('cols', ''))
@all = rows + columns
raise ArgumentError if all.length > 8 || all.map { _1['field'] }.uniq.length != all.length
end
def expressions
@expressions ||= all.map do |axis|
field = axis.fetch('field')
case field
when 'tags'
"CASE WHEN tag_entry.tag_id IS NOT NULL " +
"THEN CONCAT('internal:', tag_entry.tag_id) " +
"ELSE CONCAT('external:', tag_entry.external_tag_id) END"
when 'changed_field' then 'changed.field_key'
else
expression = @definition.expression(field)
@definition.fields[field] == :datetime ? self.class.time_expression(
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end
end
end
def joins(fields = all.map { _1['field'] })
result = ''
if fields.include?('tags')
result += <<~SQL.squish
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
SQL
end
if fields.include?('changed_field')
fields = @definition.changes.map do |field|
"SELECT #{ ApplicationRecord.connection.quote(field) } AS field_key"
end.join(' UNION ALL ')
condition = @definition.changes.map do |field|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
end.join(' OR ')
result += " JOIN (#{ fields }) changed ON (#{ condition })"
end
result
end
private
def parse(value)
axes = QueryState.axes(value)
raise ArgumentError unless axes.is_a?(Array)
axes.each do |axis|
raise ArgumentError unless axis.is_a?(Hash) && (axis.keys - ['field', 'grain']).empty?
raise ArgumentError unless @definition.axes(@population.history).include?(axis['field'])
if @definition.fields[axis['field']] == :datetime
raise ArgumentError unless GRAINS.include?(axis.fetch('grain', 'month'))
axis['grain'] ||= 'month'
elsif axis.key?('grain')
raise ArgumentError
end
end
axes
end
end
-330
ファイルの表示
@@ -1,330 +0,0 @@
# Typed predicates over the same recorded fields used by Counts output.
# Multi-valued comparisons use EXISTS; inequality means no matching element.
class CountsCondition
CHILD_TYPES = { 'id' => :integer, 'versionNo' => :integer, 'tagId' => :integer,
'tagGroupId' => :integer, 'autoGenerated' => :boolean,
'category' => :enum, 'kind' => :enum, 'sections' => :interval }.freeze
def self.fields(definition)
result = definition.metadata.map { _1.merge(labels: [definition.field_label(_1[:key])]) }
visit = lambda do |fields, prefix, labels|
fields.each do |field|
key = [prefix, field[:key]].compact.join('.')
path_labels = labels + [field[:label]]
type = if prefix
CHILD_TYPES.fetch(field[:key], :string)
elsif key == 'is_latest'
:boolean
elsif key == 'parents'
:integer
elsif key == 'deprecated'
:datetime
elsif key == 'thumbnail'
:string
else
definition.fields.fetch(key, :string)
end
type = :string if field[:children] || type == :json
type = :integer if ['parents', 'members'].include?(key)
physical = result.find { _1[:key] == key }
nullable = prefix.present? || (physical ? physical[:nullable] : key != 'is_latest')
nullable = false if field[:children]
entry = { key:, type:, labels: path_labels, nullable:,
multiple: prefix.present? || field[:children].present?,
collection: field[:children].present? || type == :interval,
operators: CountsResourceDefinition::OPERATORS.fetch(type) }
result.reject! { _1[:key] == key }
result << entry
visit.call(field[:children] || [], key, path_labels)
end
end
visit.call(definition.output_fields, nil, [])
# The raw JSON alias has the same membership semantics as the public tag field.
if definition.resource == 'posts'
result.find { _1[:key] == 'tags_json' }.merge!(type: :string, multiple: true, collection: true,
operators: CountsResourceDefinition::OPERATORS.fetch(:string))
end
result + [{ key: 'user.id', type: :integer, nullable: true,
labels: ['history:actor', 'counts:columns.resourceId'],
operators: CountsResourceDefinition::OPERATORS.fetch(:integer) },
{ key: 'user.name', type: :string, nullable: true,
labels: ['history:actor', 'history:name'],
operators: CountsResourceDefinition::OPERATORS.fetch(:string) }]
end
def initialize(population)
@population = population
@definition = population.definition
@connection = @definition.model.connection
@language = Locale.find_by!(code: population.params.fetch('locale', 'ja')).language_code
end
def apply(scope, entry)
raise ArgumentError unless entry.is_a?(Hash)
raise ArgumentError if (entry.keys - ['field', 'operator', 'value', 'end', 'blank']).any?
field = @definition.filter_metadata.find { _1[:key] == entry.fetch('field') }
raise ArgumentError unless field && field[:operators].include?(entry.fetch('operator'))
raise ArgumentError unless [true, false, nil].include?(entry['blank'])
values = [entry['value'], entry['end']]
raise ArgumentError unless values.all? { _1.nil? || (_1.is_a?(String) && _1.bytesize <= 8192) }
if entry['operator'] != 'range' && !values[1].nil? && values[1] != ''
raise ArgumentError
end
source, expression = source_for(field[:key])
type = field[:type]
blank = blank_sql(expression, type)
if entry['blank']
sql = if source && field[:collection]
"NOT EXISTS (SELECT 1 FROM #{ source })"
elsif source
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
else blank
end
return scope.where(Arel.sql(sql))
end
return scope if values.all? { _1.nil? || _1 == '' }
parsed = values.map { parse(_1, type) }
validate_enum!(field[:key], parsed.first) if type == :enum
operator = entry['operator']
if type == :interval
return interval(scope, source, expression, parsed)
end
clauses = []
binds = []
if operator == 'range'
['>=', '<='].each_with_index do |comparison, index|
next if parsed[index].nil?
clauses << "#{ expression } #{ comparison } ?"
binds << parsed[index]
end
raise ArgumentError if parsed.all? && parsed.first > parsed.last
elsif !parsed.first.nil?
comparison = { 'eq' => '=', 'ne' => '=', 'gt' => '>', 'lt' => '<',
'ge' => '>=', 'le' => '<=', 'like' => 'LIKE' }.fetch(operator)
clauses << "#{ expression } #{ comparison } ?"
binds << parsed.first
end
return scope if clauses.empty?
predicate = clauses.join(' AND ')
if source
predicate = "EXISTS (SELECT 1 FROM #{ source } WHERE #{ predicate })"
predicate = "NOT (#{ predicate })" if operator == 'ne'
elsif operator == 'ne'
predicate = "(NOT (#{ predicate }) OR #{ blank })"
end
scope.where(predicate, *binds)
end
private
def parse(value, type)
return nil if value.nil? || value == ''
case type
when :integer, :interval
raise ArgumentError unless value.match?(/\A-?[0-9]{1,19}\z/)
number = Integer(value, 10)
raise ArgumentError unless number.between?(-9_223_372_036_854_775_808, 9_223_372_036_854_775_807)
number
when :boolean
raise ArgumentError unless ['true', 'false'].include?(value)
value == 'true' ? 1 : 0
when :datetime then @population.timestamp(value)
else value
end
end
def validate_enum!(key, value)
allowed = case key.split('.').last
when 'event_type' then VersionRecord::DEFAULT_EVENT_TYPE_MAP.values
when 'category', 'tag_category' then TagVersion.categories.keys
when 'kind' then ['internal', 'external']
when 'source_kind' then Material::SOURCE_KINDS
end
raise ArgumentError if value && allowed && !allowed.include?(value)
end
def blank_sql(expression, type)
if [:string, :enum].include?(type)
"(#{ expression } IS NULL OR #{ expression } = '')"
else
"#{ expression } IS NULL"
end
end
def scalar(document, key, type = :string)
path = @connection.quote("$.#{ key }")
value = "JSON_EXTRACT(#{ document }, #{ path })"
text = "JSON_UNQUOTE(NULLIF(#{ value }, CAST('null' AS JSON)))"
return "CAST(#{ text } AS SIGNED)" if type == :integer
return "CASE #{ text } WHEN 'true' THEN 1 WHEN 'false' THEN 0 END" if type == :boolean
text
end
def json_table(document, path, name)
"JSON_TABLE(COALESCE(#{ document }, JSON_ARRAY()), #{ @connection.quote(path) } " +
"COLUMNS (item JSON PATH '$')) #{ name }"
end
def names_source(document, alias_name)
source = "JSON_TABLE(COALESCE(JSON_KEYS(#{ document }), JSON_ARRAY()), '$[*]' " +
"COLUMNS (language_code TEXT PATH '$')) #{ alias_name }"
path = "CONCAT('$.', JSON_QUOTE(#{ alias_name }.language_code))"
[source, "JSON_EXTRACT(#{ document }, #{ path })", "#{ alias_name }.language_code"]
end
def point
@point ||= @population.history ? @population.at :
@population.timestamp(@population.params['at']) || @population.at
end
def name_expression(row)
language_path = @connection.quote("$.#{ @language.to_json }.name")
name = "JSON_UNQUOTE(JSON_EXTRACT(#{ row }.names, #{ language_path }))"
"COALESCE(#{ name }, #{ row }.name)"
end
def parent_source
column = @definition.resource == 'posts' ? 'parent_post_ids' : 'parent_tag_ids'
ids = "TRIM(REGEXP_REPLACE(COALESCE(v.#{ column }, ''), '[[:space:]]+', ' '))"
document = "CONCAT('[', REPLACE(#{ ids }, ' ', ','), ']')"
source = json_table(document, '$[*]', 'parent_entry')
id = 'CAST(parent_entry.item AS UNSIGNED)'
return [source, id] if @definition.resource == 'posts'
bound = @connection.quote(point)
source += ' LEFT JOIN tag_versions parent_version ON parent_version.tag_id = ' + id +
" AND parent_version.created_at <= #{ bound } AND " +
HistoryAccess.sql(TagVersion, 'parent_version') +
' AND NOT EXISTS (SELECT 1 FROM tag_versions newer_parent ' +
'WHERE newer_parent.tag_id = parent_version.tag_id ' +
"AND newer_parent.created_at <= #{ bound } " +
'AND newer_parent.version_no > parent_version.version_no)'
[source, id]
end
def post_tag_source
source = json_table('v.tags_json', '$[*]', 'tag_snapshot')
source += ' LEFT JOIN tag_versions referenced_tag ON referenced_tag.tag_id = ' +
scalar('tag_snapshot.item', 'tag_id', :integer) +
' AND referenced_tag.version_no = ' + scalar('tag_snapshot.item', 'version_no', :integer) +
" AND referenced_tag.created_at <= #{ @connection.quote(point) } AND " +
HistoryAccess.sql(TagVersion, 'referenced_tag')
source
end
def language_field(source, document, parts, inherited_language = nil)
return [source, scalar(document, 'name')] if parts.empty?
return [source, inherited_language] if parts == ['languageCode'] && inherited_language
if parts.first == 'aliases'
aliases = "JSON_EXTRACT(#{ document }, '$.aliases')"
source += ' JOIN ' + json_table(aliases, '$[*]', 'alias_entry')
child = parts[1] || 'name'
return [source, inherited_language] if child == 'languageCode' && inherited_language
columns = { 'scriptCode' => 'script_code', 'name' => 'name' }
key = columns.fetch(child) { raise ArgumentError }
return [source, scalar('alias_entry.item', key)]
end
key = { 'name' => 'name', 'scriptCode' => 'script_code',
'autoGenerated' => 'auto_generated' }.fetch(parts.first) { raise ArgumentError }
[source, scalar(document, key, CHILD_TYPES.fetch(parts.first, :string))]
end
def source_for(key)
root, *parts = key.split('.')
return [nil, "(#{ @population.latest_sql })"] if root == 'is_latest'
if root == 'user' && parts.any?
expression = parts.first == 'id' ? 'v.created_by_user_id' :
'(SELECT actor.name FROM users actor WHERE actor.id = v.created_by_user_id)'
return [nil, expression]
end
return [nil, 'v.thumbnail_base'] if root == 'thumbnail'
return [nil, 'v.deprecated_at'] if root == 'deprecated'
if root == 'parents'
source, id = parent_source
return [source, parts.first == 'name' ? name_expression('parent_version') : id]
end
if root == 'tags' || root == 'tags_json'
return post_field(parts)
end
if root == 'names' || root == 'aliases'
source, document, language = names_source('v.names', 'name_language')
parts = ['aliases', *parts] if root == 'aliases'
return language_field(source, document, parts, language)
end
if root == 'members' || root == 'placements'
path = '$[*]' + parts.take_while { _1 == 'children' }.map { '.children[*]' }.join
source = json_table("v.#{ root }", path, 'structure_entry')
if root == 'members' && parts.empty?
value = "COALESCE(#{ scalar('structure_entry.item', 'tag_id', :integer) }, " +
"#{ scalar('structure_entry.item', 'tag_group_id', :integer) })"
return [source, value]
end
leaf = parts.last || 'sectionName'
leaf = 'sectionName' if leaf == 'children'
column = leaf.gsub(/[A-Z]/) { "_#{ _1.downcase }" }
return [source, scalar('structure_entry.item', column, CHILD_TYPES.fetch(leaf, :string))]
end
if root == 'export_paths_json'
source, value, profile = names_source('v.export_paths_json', 'export_profile')
return [source, parts.first == 'profile' ? profile : "JSON_UNQUOTE(#{ value })"]
end
return [nil, name_expression('v')] if root == 'name' && @definition.resource == 'tags'
[nil, @definition.expression(root)]
end
def post_field(parts)
source = post_tag_source
document = 'tag_snapshot.item'
case parts.first
when 'names'
names, entry, language = names_source('referenced_tag.names', 'tag_language')
language_field("#{ source } JOIN #{ names }", entry, parts.drop(1), language)
when 'sections'
sections = "JSON_EXTRACT(#{ document }, '$.sections')"
source += ' JOIN ' + json_table(sections, '$[*]', 'section_entry')
[source, 'section_entry.item']
when 'id'
[source, "COALESCE(#{ scalar(document, 'tag_id', :integer) }, " +
"#{ scalar(document, 'external_tag_id', :integer) })"]
when 'versionNo' then [source, scalar(document, 'version_no', :integer)]
when 'kind'
[source, "CASE WHEN #{ scalar(document, 'tag_id') } IS NULL " +
"THEN 'external' ELSE 'internal' END"]
when 'category'
[source, "COALESCE(#{ scalar(document, 'category') }, referenced_tag.category)"]
else
[source, "COALESCE(#{ scalar(document, 'name') }, referenced_tag.name)"]
end
end
def interval(scope, source, document, bounds)
raise ArgumentError if bounds.compact.any?(&:negative?)
raise ArgumentError if bounds.all? && bounds.first > bounds.last
first = scalar(document, 'begin_ms', :integer)
last = scalar(document, 'end_ms', :integer)
clauses = []
values = []
if bounds.first
clauses << "(#{ last } IS NULL OR #{ last } > ?)"
values << bounds.first
end
if bounds.last
clauses << "#{ first } < ?"
values << bounds.last
end
scope.where("EXISTS (SELECT 1 FROM #{ source } WHERE #{ clauses.join(' AND ') })", *values)
end
end
-63
ファイルの表示
@@ -1,63 +0,0 @@
# Only confirmed combinations belong here; all others retain the existing behaviour.
class CountsDefaults
POST_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'title', 'url',
'thumbnail_base', 'tags', 'parents', 'original_created_from',
'original_created_before', 'event_type', 'user', 'created_at'].freeze
def self.entries(resource)
result = [{ definition: 'history', mode: 'summary', params: {
'rows' => 'created_at:month',
'cols' => 'event_type',
'sort' => 'a0:desc', 'conditions' => '' } }]
result << { definition: 'latest', mode: 'summary', params: {
'rows' => '', 'cols' => '', 'conditions' => '', 'tags' => '' } }
details = detail_params(resource)
if details
['latest', 'history'].each do |definition|
result << { definition:, mode: 'details', params: details }
end
end
result
end
def self.detail_params(resource)
if resource == 'posts'
return { 'columns' => POST_COLUMNS.join(' '), 'order' => POST_COLUMNS.join(' '),
'children' => 'tags.name parents.id', 'nested_json' => '0',
'conditions' => '', 'tags' => '' }
end
unless resource == 'tags'
return { 'columns' => CountsExportProjection.default_columns(resource).join(' '),
'children' => CountsExportProjection.default_children(resource).join(' ') }
end
columns = ['resource_id', 'version_no', 'is_latest', 'name', 'category',
'aliases', 'parents', 'deprecated', 'event_type', 'user', 'created_at']
{ 'columns' => columns.join(' '), 'order' => columns.join(' '),
'children' => 'aliases.name parents.name', 'nested_json' => '0' }
end
def self.for(params)
settings = entries(params.fetch('resource', 'posts')).find do |entry|
entry[:definition] == params.fetch('definition', 'latest') &&
entry[:mode] == params.fetch('mode', 'summary')
end&.fetch(:params) || {}
if settings.key?('sort') && params.key?('rows')
actual = QueryState.axes(params['rows'])
expected = QueryState.axes(settings.fetch('rows'))
compatible = actual.is_a?(Array) && actual.first.is_a?(Hash) &&
actual.first['field'] == expected.first['field'] &&
actual.first.fetch('grain', 'month') == expected.first['grain']
unless compatible
settings = settings.except('sort')
end
end
settings
end
def self.metadata(resource)
entries(resource).map do |entry|
entry.merge(params: entry[:params].map { |key, value| { key:, value: } })
end
end
end
-106
ファイルの表示
@@ -1,106 +0,0 @@
require 'tempfile'
require 'fileutils'
# Finish generation before reporting readiness; downloads never hold the full file in RAM.
class CountsExportDownload
EXPIRY = 10.minutes
MAX_CONCURRENT_EXPORTS = 2
MAX_FILE_BYTES = 128.megabytes
MAX_STORED_BYTES = 512.megabytes
MAX_EXPORT_SECONDS = 120
class Busy < StandardError; end
class TooLarge < StandardError; end
class TimedOut < StandardError; end
class StorageFull < StandardError; end
def self.directory
Rails.root.join(ENV.fetch('COUNTS_EXPORT_DIRECTORY', 'tmp/counts_exports'))
end
def self.verifier = Rails.application.message_verifier('counts_export_download')
def self.prepare(exporter, owner, progress: nil, check: nil, identifier: nil)
FileUtils.mkdir_p(directory, mode: 0o700)
authority = HistoryAccess.authority
slot = acquire_slot
begin
generate(exporter, owner, progress:, check:, identifier:, authority:)
ensure
slot.close
end
end
def self.acquire_slot
MAX_CONCURRENT_EXPORTS.times do |index|
path = directory.join(".slot-#{ index }.lock")
handle = File.open(path, File::RDWR | File::CREAT, 0o600)
return handle if handle.flock(File::LOCK_EX | File::LOCK_NB)
handle.close
end
raise Busy
end
def self.generate(exporter, owner, progress: nil, check: nil, identifier: nil, authority: HistoryAccess.authority)
FileUtils.mkdir_p(directory, mode: 0o700)
Dir.glob(directory.join('counts-*.export')).each do |path|
File.unlink(path) if File.mtime(path) < Time.current - EXPIRY
rescue Errno::ENOENT
# Another request may have removed an expired download.
end
stored = Dir.glob(directory.join('counts-*')).sum do |path|
File.size(path)
rescue Errno::ENOENT
0
end
reserved = MAX_CONCURRENT_EXPORTS * MAX_FILE_BYTES
raise StorageFull if stored + reserved > MAX_STORED_BYTES
prefix = identifier ? "counts-#{ identifier }-" : 'counts-'
file = Tempfile.create([prefix, '.part'], directory)
complete = false
bytes = 0
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MAX_EXPORT_SECONDS
path = file.path
begin
file.binmode
exporter.body(progress:).each do |chunk|
check&.call
raise TimedOut if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
bytes += chunk.bytesize
raise TooLarge if bytes > MAX_FILE_BYTES
file.write(chunk)
end
file.flush
file.fsync
file.close
finished_path = path.sub(/\.part\z/, '.export')
File.rename(path, finished_path)
path = finished_path
token = verifier.generate({ 'file' => File.basename(path),
'resource' => exporter.resource, 'format' => exporter.format,
'owner' => owner, **authority }, expires_in: EXPIRY, purpose: 'counts_export')
complete = true
token
ensure
file.close unless file.closed?
File.unlink(path) if !complete && File.exist?(path)
end
end
def self.find(token, owner)
metadata = verifier.verified(token, purpose: 'counts_export')
return unless metadata.is_a?(Hash) && metadata['owner'] == owner
return unless HistoryAccess.authorised_artifact?(metadata)
return unless metadata['file']&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
return unless CountsResourceDefinition::DEFINITIONS.key?(metadata['resource'])
return unless ['json', 'csv'].include?(metadata['format'])
path = directory.join(metadata['file'])
return unless path.file?
metadata.merge('path' => path.to_s)
end
end
-255
ファイルの表示
@@ -1,255 +0,0 @@
require 'json'
require 'fileutils'
# A durable, bounded export spool. Web and supervised workers share the same POSIX volume.
class CountsExportJobs
class Cancelled < StandardError; end
ACTIVE = ['queued', 'running', 'cancelling'].freeze
def self.directory = CountsExportDownload.directory.join('jobs')
def self.verifier = Rails.application.message_verifier('counts_export_job')
def self.synchronise
FileUtils.mkdir_p(directory, mode: 0o700)
File.open(directory.join('.registry.lock'), File::RDWR | File::CREAT, 0o600) do |lock|
lock.flock(File::LOCK_EX)
yield
end
end
def self.write(state)
path = directory.join("#{ state.fetch('id') }.json")
temporary = "#{ path }.#{ SecureRandom.hex(8) }.tmp"
File.open(temporary, 'w', 0o600) do |file|
file.write(JSON.generate(state))
file.flush
file.fsync
end
File.rename(temporary, path)
ensure
File.unlink(temporary) if temporary && File.exist?(temporary)
end
def self.read(id)
JSON.parse(File.read(directory.join("#{ id }.json")))
rescue Errno::ENOENT
nil
end
def self.remove_download(state)
name = state['download_file']
return unless name&.match?(/\Acounts-[a-zA-Z0-9_-]+\.export\z/)
File.unlink(CountsExportDownload.directory.join(name))
rescue Errno::ENOENT
nil
end
def self.remove_partial(id)
Dir.glob(CountsExportDownload.directory.join("counts-#{ id }-*")).each do |path|
File.unlink(path)
rescue Errno::ENOENT
nil
end
end
def self.clean
Dir.glob(directory.join('*.tmp')).each do |path|
File.unlink(path) if File.mtime(path) < Time.current - CountsExportDownload::EXPIRY
end
Dir.glob(directory.join('*.run')).each do |path|
id = File.basename(path, '.run')
next if directory.join("#{ id }.json").exist?
File.open(path, File::RDWR) do |lock|
File.unlink(path) if lock.flock(File::LOCK_EX | File::LOCK_NB)
end
end
Dir.glob(directory.join('*.json')).each do |path|
state = JSON.parse(File.read(path))
next if state.fetch('expires_at') > Time.current.to_f
run = directory.join("#{ state.fetch('id') }.run")
File.open(run, File::RDWR | File::CREAT, 0o600) do |lock|
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
state['status'] = 'cancelling'
write(state)
next
end
remove_download(state)
remove_partial(state.fetch('id'))
File.unlink(path)
File.unlink(run)
end
end
end
def self.start(payload, owner, language)
payload = payload.dup
time_key = payload.key?('definition') ? 'execution_at' : 'at'
if payload[time_key].nil? || payload[time_key] == ''
payload[time_key] = Time.current.utc.iso8601(6)
end
exporter = CountsExporter.new(payload, language_code: language)
synchronise do
clean
active = Dir.glob(directory.join('*.json')).count do |path|
ACTIVE.include?(JSON.parse(File.read(path))['status'])
end
raise CountsExportDownload::Busy if active >= CountsExportDownload::MAX_CONCURRENT_EXPORTS
id = SecureRandom.hex(24)
state = { 'id' => id, 'owner' => owner, 'payload' => payload,
'language' => language, 'resource' => exporter.resource,
'format' => exporter.format, 'status' => 'queued',
'processed' => 0, 'total' => nil,
**HistoryAccess.authority,
'expires_at' => (Time.current + CountsExportDownload::EXPIRY).to_f }
write(state)
verifier.generate({ 'id' => id, 'owner' => owner },
expires_in: CountsExportDownload::EXPIRY, purpose: 'counts_export_job')
end
end
def self.authorised_id(token, owner)
value = verifier.verified(token, purpose: 'counts_export_job')
return unless value.is_a?(Hash) && value['owner'] == owner
return unless value['id']&.match?(/\A[a-f0-9]{48}\z/)
value['id']
end
def self.status(token, owner, cancel: false)
id = authorised_id(token, owner)
return unless id
synchronise do
state = read(id)
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
return unless HistoryAccess.authorised_artifact?(state)
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
remove_download(state)
state.delete('token')
write(state)
end
state.slice('status', 'processed', 'total', 'format', 'token', 'error')
end
end
def self.checkpoint(id, processed = nil, total = nil)
synchronise do
state = read(id)
unless state && state['status'] == 'running' && state['expires_at'] > Time.current.to_f
raise Cancelled
end
if processed
state.merge!('processed' => processed, 'total' => total)
write(state)
end
end
end
def self.work_one
FileUtils.mkdir_p(directory, mode: 0o700)
Dir.glob(directory.join('*.json')).sort.each do |path|
id = File.basename(path, '.json')
File.open(directory.join("#{ id }.run"), File::RDWR | File::CREAT, 0o600) do |lock|
next unless lock.flock(File::LOCK_EX | File::LOCK_NB)
state = synchronise do
candidate = read(id)
next unless candidate && ACTIVE.include?(candidate['status'])
if candidate['status'] != 'queued'
candidate['status'] = candidate['status'] == 'cancelling' ? 'cancelled' : 'failed'
remove_partial(id)
candidate['error'] = 'interrupted'
write(candidate)
next
end
candidate['status'] = 'running'
write(candidate)
candidate
end
next unless state
perform(state)
return true
end
end
synchronise { clean }
false
end
def self.perform(state)
actor = state['actor_id'] && User.find_by(id: state['actor_id'])
actor = nil unless state['admin'] && actor&.admin? && !actor.banned?
HistoryAccess.set(user: actor) do
unless HistoryAccess.authorised_artifact?(state)
synchronise do
current = read(state.fetch('id'))
current['status'] = 'failed'
write(current)
end
return
end
perform_authorised(state)
end
end
def self.perform_authorised(state)
id = state.fetch('id')
token = nil
begin
checkpoint(id)
exporter = CountsExporter.new(state.fetch('payload'), language_code: state.fetch('language'))
last = Process.clock_gettime(Process::CLOCK_MONOTONIC)
progress = lambda do |processed, total|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
if processed.zero? || now - last >= 0.25 || processed == total
checkpoint(id, processed, total)
last = now
end
end
check = lambda do
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
if now - last >= 0.25
checkpoint(id)
last = now
end
end
token = CountsExportDownload.prepare(exporter, state.fetch('owner'),
progress:, check:, identifier: id)
synchronise do
current = read(id)
if (!current || current['status'] != 'running' ||
current['expires_at'] <= Time.current.to_f)
file = CountsExportDownload.find(token, state.fetch('owner'))
File.unlink(file.fetch('path')) if file
raise Cancelled
end
file = CountsExportDownload.find(token, state.fetch('owner'))
current.merge!('status' => 'ready', 'token' => token,
'download_file' => File.basename(file.fetch('path')))
current['total'] = current['processed']
write(current)
end
rescue StandardError => error
remove_partial(id)
synchronise do
current = read(id)
if current
current['status'] = error.is_a?(Cancelled) ? 'cancelled' : 'failed'
current['error'] = error.class.name
write(current)
end
end
unless error.is_a?(Cancelled)
Rails.logger.error("counts_export_job #{ id }: #{ error.class }")
end
end
end
end
-265
ファイルの表示
@@ -1,265 +0,0 @@
require 'set'
# Export identifiers are stable; UI translations are supplied separately as column labels.
class CountsExportProjection
ROOT_KEYS = { 'event_type' => 'event_type', 'resource_id' => 'resource_id', 'version_no' => 'version_no',
'is_latest' => 'is_latest', 'thumbnail' => 'thumbnail_url',
'thumbnail_base' => 'thumbnail_base', 'title' => 'title', 'url' => 'url',
'tags' => 'tags', 'parents' => 'parents', 'video_ms' => 'video_ms',
'original_created_from' => 'original_created_from',
'original_created_before' => 'original_created_before',
'user' => 'created_by_user', 'created_at' => 'created_at',
'name' => 'name', 'names' => 'names', 'category' => 'category',
'aliases' => 'aliases', 'deprecated' => 'deprecated' }.freeze
CHILD_KEYS = { 'id' => 'id', 'versionNo' => 'version_no', 'kind' => 'kind',
'name' => 'name', 'category' => 'category', 'sections' => 'sections',
'names' => 'names', 'aliases' => 'aliases',
'languageCode' => 'language', 'scriptCode' => 'script',
'autoGenerated' => 'auto_generated' }.freeze
NAME_FIELDS = ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated'].freeze
CHILDREN = {
'tags' => ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names'],
'names' => NAME_FIELDS,
'aliases' => ['languageCode', 'name', 'scriptCode'],
'parents' => ['id', 'name'] }.freeze
def self.default_columns(resource)
fields = { 'posts' => ['title', 'url', 'tags', 'parents', 'video_ms'],
'tags' => ['name', 'category', 'aliases', 'parents'],
'nico_tags' => ['name', 'linked_tags'],
'tag_descriptions' => ['language_code', 'body', 'source_language_code'],
'tag_groups' => ['name', 'description', 'members'],
'materials' => ['url', 'tag_id', 'source_kind'] }.fetch(resource)
['resource_id', 'version_no'] + fields + ['event_type', 'user', 'created_at']
end
def self.default_children(resource)
{ 'posts' => ['tags.name', 'parents.id'],
'tags' => ['aliases.name', 'parents.id'],
'tag_groups' => ['members.tagId'] }.fetch(resource, [])
end
attr_reader :columns, :headers
def initialize(params, resource:, summary:, tags:)
if params.key?('definition')
params = CountsDefaults.for(params.merge('resource' => resource)).merge(params)
end
@params = params
@labels = JSON.parse(params.fetch('labels', '{}'))
raise ArgumentError unless @labels.is_a?(Hash) && @labels.length <= 100
raise ArgumentError unless @labels.all? { |key, value| valid_label?(key, value) }
@summary = summary
allowed = if summary
tags ? ['kind', 'tag_id', 'name', 'deprecated', 'count'] : ['group', 'count']
else
CountsQuery::COLUMNS.fetch(resource)
end
defaults = summary ? allowed : self.class.default_columns(resource)
@columns = QueryState.list(params.fetch('columns', defaults.join(' ')))
raise ArgumentError if (@columns - allowed).any? || @columns.uniq != @columns
@headers = JSON.parse(params.fetch('headers', JSON.generate(@columns)))
unless (@headers.is_a?(Array) && @headers.length == @columns.length &&
@headers.all? { _1.is_a?(String) && _1.length <= 200 })
raise ArgumentError
end
@order = QueryState.list(params.fetch('order', ''))
@sorts = QueryState.list(params.fetch('child_sort', '')).map do |entry|
parts = entry.split(':', -1)
raise ArgumentError unless parts.length == 2
path, direction = parts
raise ArgumentError unless ['asc', 'desc'].include?(direction)
[path, direction]
end
@selected = params.key?('children') ? QueryState.list(params['children']).to_set :
self.class.default_children(resource).to_set
valid = CHILDREN.flat_map do |key, fields|
paths(fields, key)
end
raise ArgumentError if @selected && (@selected.to_a - valid).any?
prune = lambda do |fields, prefix|
fields.each do |key|
path = "#{ prefix }.#{ key }"
children = fields_for(key)
next if children.empty?
prune.call(children, path)
@selected.delete(path) unless children.any? { @selected.include?("#{ path }.#{ _1 }") }
end
end
CHILDREN.each { |key, fields| prune.call(fields, key) }
pairs = @columns.zip(@headers).select do |key, _header|
!CHILDREN.key?(key) || CHILDREN[key].any? { @selected.include?("#{ key }.#{ _1 }") }
end
@columns = pairs.map(&:first)
@headers = pairs.map(&:last)
allowed_sorts = valid.map do |path|
parts = path.split('.')
field = parts.pop
"#{ parts.join('.') }/#{ field }"
end
if ((@sorts.map(&:first) - allowed_sorts).any? ||
@sorts.map(&:first).uniq.length != @sorts.length)
raise ArgumentError
end
end
def metadata
@columns.each_with_index.map do |key, index|
{ key: @summary ? key : ROOT_KEYS.fetch(key), label: @headers[index] }
end
end
def project(row)
@columns.to_h do |key|
if @summary
value = key == 'group' ? { key: row[:key], name: row[:name] } : row[key.to_sym]
[key, value]
else
value = case key
when 'thumbnail' then row[:thumbnail_base]
else row[ROOT_KEYS.fetch(key).to_sym]
end
value = nested(value || [], CHILDREN.fetch(key), key) if CHILDREN.key?(key)
[ROOT_KEYS.fetch(key), value]
end
end
end
def csv_record(record, row)
record.map do |key, value|
key == 'group' ? row[:name] || row[:key] : csv_display(value, key)
end
end
private
def valid_label?(key, value)
key.is_a?(String) && value.is_a?(String) && key.length <= 100 && value.length <= 300
end
def csv_display(value, key)
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
return @labels.fetch("event_type:#{ value }", value) if key == 'event_type'
return value ? @labels.fetch('latest', 'true') : '' if key == 'is_latest'
return @labels.fetch("kind:#{ value }", value) if key == 'kind'
return @labels.fetch("category:#{ value }", value) if key == 'category'
if ['language', 'script'].include?(key) && value.is_a?(Hash)
return @labels.fetch("#{ key }:#{ value[:code] }",
value[:name] || @labels.fetch('notRegistered', ''))
end
return @labels.fetch("boolean:#{ value }", value) if value == true || value == false
return value.map { csv_display(_1, key) } if value.is_a?(Array)
if value.is_a?(Hash)
return value.to_h do |field, item|
[@labels.fetch(field.to_s, field), csv_display(item, field.to_s)]
end
end
value
end
def fields_for(key)
return NAME_FIELDS if key == 'names'
return ['name', 'scriptCode'] if key == 'aliases'
[]
end
def paths(fields, prefix)
fields.flat_map do |key|
path = "#{ prefix }.#{ key }"
[path, *paths(fields_for(key), path)]
end
end
def fetch(row, key) = row.key?(key) ? row[key] : row[key.to_sym]
def named_code(row, kind)
code = fetch(row, "#{ kind }_code")
name = fetch(row, "#{ kind }_name")
{ code:, name: name.present? && name.to_s.downcase != code.to_s.downcase ? name : nil }
end
def stable(value)
case value
when Hash
value.stringify_keys.sort.to_h.transform_values { stable(_1) }
when Array
value.map { stable(_1) }
else
value
end
end
def sort_value(row, key)
if key == 'languageCode'
language = named_code(row, 'language')
return @labels.fetch("language:#{ language[:code] }", language[:name])
end
return named_code(row, 'script')[:name] if key == 'scriptCode'
fetch(row, CHILD_KEYS.fetch(key))
end
def comparison(left, right)
if left.is_a?(Array) && right.is_a?(Array) && left.length != right.length
return left.length <=> right.length
end
return left <=> right if left.is_a?(Numeric) && right.is_a?(Numeric)
if [true, false].include?(left) && [true, false].include?(right)
return (left ? 1 : 0) <=> (right ? 1 : 0)
end
a = left.is_a?(Hash) || left.is_a?(Array) ? JSON.generate(stable(left)) : left.to_s
b = right.is_a?(Hash) || right.is_a?(Array) ? JSON.generate(stable(right)) : right.to_s
a <=> b
end
def sorted(rows, prefix, fields)
sorts = @sorts.filter_map do |path, direction|
parent, key = path.split('/', 2)
next unless parent == prefix
raise ArgumentError unless fields.include?(key)
[key, direction]
end
rows.each_with_index.sort do |(left, a), (right, b)|
result = 0
sorts.each do |key, direction|
first = sort_value(left, key)
last = sort_value(right, key)
result = if first.nil? || last.nil?
first.nil? == last.nil? ? 0 : (first.nil? ? 1 : -1)
else
comparison(first, last) * (direction == 'asc' ? 1 : -1)
end
break unless result.zero?
end
result.zero? ? a <=> b : result
end.map(&:first)
end
def nested(rows, fields, prefix)
keys = fields.select { !@selected || @selected.include?("#{ prefix }.#{ _1 }") }
ordered = (@order + keys.map { "#{ prefix }.#{ _1 }" }).uniq
.filter_map { |path| keys.find { "#{ prefix }.#{ _1 }" == path } }
sorted(rows, prefix, fields).map do |row|
ordered.to_h do |key|
stable = CHILD_KEYS.fetch(key)
value = case key
when 'languageCode' then named_code(row, 'language')
when 'scriptCode' then named_code(row, 'script')
else fetch(row, stable)
end
if fields_for(key).any?
value = nested(value || [], fields_for(key), "#{ prefix }.#{ key }")
end
[stable, value]
end
end
end
end
-152
ファイルの表示
@@ -1,152 +0,0 @@
require 'csv'
# One read view and a fixed point for the entire download; only one page is held in memory.
class CountsExporter
EXTRA_KEYS = ['basis', 'format', 'resource', 'headers', 'children', 'order',
'summary_columns', 'nested_json', 'child_sort', 'labels'].freeze
attr_reader :format, :resource
def initialize(params, language_code:)
if params.key?('definition')
@table_export = CountsTableExport.new(params)
@format = @table_export.format
@resource = @table_export.resource
return
end
allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq
QueryState.validate!(params, allowed)
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@language = language_code
@format = params.fetch('format', 'json')
@resource = params.fetch('resource', 'posts')
@mode = params.fetch('mode', 'summary')
@history = params.fetch('basis', 'state') == 'history'
raise ArgumentError unless ['json', 'csv'].include?(format)
raise ArgumentError unless ['posts', 'tags'].include?(resource)
raise ArgumentError unless ['summary', 'details'].include?(@mode)
raise ArgumentError unless ['state', 'history'].include?(params.fetch('basis', 'state'))
@query_params = params.except(*EXTRA_KEYS, 'columns', 'page', 'limit')
.merge('mode' => @mode, 'page' => '1', 'limit' => '100')
point = CountsStateQuery.new(@query_params.slice('at').merge('resource' => resource),
language_code: @language).at
@query_params['at'] = point.iso8601(6)
@query_params['axis'] ||= !@history && resource == 'posts' ? 'tags' : 'month'
@projection = CountsExportProjection.new(params, resource:,
summary: @mode == 'summary',
tags: !@history && @query_params['axis'] == 'tags')
if @history
@query_params.delete('kind')
history_query
else
@query_params.delete('latest')
@state_query = CountsStateQuery.new(@query_params.merge('resource' => resource),
language_code: @language).validate_query!
end
end
def body(progress: nil)
return @table_export.body(progress:) if @table_export
Enumerator.new do |output|
ApplicationRecord.transaction(isolation: :repeatable_read) do
processed = 0
first_row = true
started = false
pages do |page|
total = @mode == 'details' ? page[:count] : nil
progress&.call(processed, total)
unless started
meta = { resource:, mode: @mode, basis: @history ? 'history' : 'state',
at: @query_params['at'], boundary: 'inclusive',
filters: @query_params.except('sort', 'page', 'limit', 'mode'),
sort: export_sort,
child_sort: sort_metadata(@params.fetch('child_sort', '')),
columns: @projection.metadata,
children: @params['children']&.split(' '),
field_order: @QueryState.list(params.fetch('order', '')), timezone: 'UTC',
count: @mode == 'summary' ? page[:group_count] : page[:count],
totals: page[:totals], coverage: page[:coverage] }
output << (format == 'json' ? "{\"metadata\":#{ meta.to_json },\"data\":[" : "\uFEFF")
if format == 'csv'
output << CSV.generate_line(
@projection.headers.map { csv_value(_1) }, row_sep: "\r\n")
end
started = true
end
(page[:versions] || page[:state_rows] || page[:groups] || []).each do |row|
record = @projection.project(row)
if format == 'json'
output << ',' unless first_row
output << record.to_json
else
values = @projection.csv_record(record, row).map { csv_value(_1) }
output << CSV.generate_line(values, row_sep: "\r\n")
end
first_row = false
processed += 1
progress&.call(processed, total)
end
end
progress&.call(processed, processed)
output << ']}' if format == 'json'
end
end
end
private
def export_sort
sort_metadata(@query_params.fetch('sort', default_sort))
end
def sort_metadata(value)
value.split(' ').map do |entry|
key, direction = entry.split(':', 2)
{ key:, direction: }
end
end
def default_sort
return 'created_at:desc' if @mode == 'details'
!@history && @query_params['axis'] == 'tags' ? 'count:desc' : 'group:asc'
end
def history_query
scope = resource == 'posts' ? PostVersion.with_previous_version : TagVersion.all
CountsQuery.new(scope, resource, @query_params)
end
def pages
unless @history
@state_query.each_page { yield _1 }
return
end
query = history_query
total = @mode == 'summary' ? nil : query.count
loop do
result = if @mode == 'summary'
query.summary(include_count: @query_params['page'] == '1')
else
{ count: total,
versions: CountsVersionRepr.many(query.details, resource:,
language_code: @language,
at: Time.iso8601(@query_params['at'])) }
end
total ||= result[:group_count]
yield result
break if @query_params['page'].to_i * 100 >= total
@query_params['page'] = (@query_params['page'].to_i + 1).to_s
query = history_query
end
end
def csv_value(value)
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
end
end
-28
ファイルの表示
@@ -1,28 +0,0 @@
# Coordinates carry their aggregation depth; SQL NULL remains an ordinary axis value.
class CountsPivotLayout
def self.depths(keys, sorts, measures: true)
return [] if keys.length < 2 || sorts.first&.first == 'count' ||
sorts.first&.first&.match?(/\Am[0-9]+\z/)
relevant = sorts.map(&:first).select { keys.include?(_1) || (measures && (_1 == 'count' ||
_1.match?(/\Am[0-9]+\z/))) }
order = (relevant + keys).uniq
(1...keys.length).select { order.first(_1).sort == keys.first(_1).sort }
end
def self.headers(values, depths, next_values: nil, grand: false)
result = []
values.each_with_index do |tuple, index|
result << { values: tuple, kind: 'data' }
following = values[index + 1] || next_values
depths.reverse_each do |depth|
prefix = tuple.first(depth)
next if following && following.first(depth) == prefix
result << { values: prefix, kind: 'subtotal' }
end
end
result << { values: [], kind: 'grand_total' } if grand
result
end
end
-59
ファイルの表示
@@ -1,59 +0,0 @@
# Re-aggregate the population, never add up already grouped cells (multi-value axes overlap).
class CountsPivotTotals
def initialize(population, axes, row_headers, column_headers)
@population = population
@axes = axes
@rows = row_headers.group_by { _1[:values].length }
@columns = column_headers.group_by { _1[:values].length }
end
def call
queries = @rows.flat_map do |row_depth, headers|
@columns.filter_map do |column_depth, columns|
next if row_depth == @axes.rows.length && column_depth == @axes.columns.length
indexes = (0...row_depth).to_a +
(0...column_depth).map { @axes.rows.length + _1 }
expressions = indexes.map { @axes.expressions[_1] }
scope = @population.scope.except(:select, :order)
joins = @axes.joins(indexes.map { @axes.all[_1].fetch('field') })
scope = scope.joins(joins) unless joins.empty?
if row_depth.positive?
tuples = headers.map { _1[:values] }.uniq
predicate = tuples.map { Array.new(row_depth) { |i|
"(#{ expressions[i] }) <=> ?"
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
scope = scope.where(predicate, *tuples.flatten)
end
if column_depth.positive?
tuples = columns.map { _1[:values] }.uniq
predicate = tuples.map { Array.new(column_depth) { |i|
"(#{ expressions[row_depth + i] }) <=> ?"
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
scope = scope.where(predicate, *tuples.flatten)
end
grouped = expressions.each_with_index.map { |expression, index|
"#{ expression } AS v#{ index }"
}
# Fixed-width result coordinates, independent of NULL and of translated labels.
padding = (expressions.length...@axes.all.length).map { "NULL AS v#{ _1 }" }
fields = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
grouped + padding + ['COUNT(DISTINCT v.id) AS count']
scope = scope.group(*expressions.map { Arel.sql(_1) }) if expressions.any?
scope.optimizer_hints('MAX_EXECUTION_TIME(10000)')
.select(Arel.sql(fields.join(', '))).to_sql
end
end
return [] if queries.empty?
sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ')
@population.definition.model.connection.select_all(
"SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sql }) totals").map do |record|
row_depth = record['row_depth'].to_i
column_depth = record['column_depth'].to_i
{ row: (0...row_depth).map { record["v#{ _1 }"] },
column: (0...column_depth).map { record["v#{ row_depth + _1 }"] },
count: record['count'].to_i }
end
end
end
-168
ファイルの表示
@@ -1,168 +0,0 @@
# Population first, then filters. Previous versions are joined before period filtering.
class CountsPopulation
attr_reader :definition, :params, :at, :history, :scope, :timezone
def initialize(params)
@params = params
@definition = CountsResourceDefinition.new(params.fetch('resource', 'posts'))
@history = params.fetch('definition', 'latest') == 'history'
raise ArgumentError unless ['latest', 'history'].include?(params.fetch('definition', 'latest'))
@timezone = TZInfo::Timezone.get(params.fetch('timezone', 'UTC'))
@at = timestamp(params['execution_at']) || Time.current.utc
raise ArgumentError if at > Time.current + 1.second
@scope = definition.model.all
make_population
@scope = HistoryAccess.visible(@scope, 'v')
apply_filters
rescue TZInfo::InvalidTimezoneIdentifier
raise ArgumentError
end
def timestamp(value, end_date: false)
return nil if value.nil? || value == ''
raise ArgumentError unless value.is_a?(String)
if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
date = Date.iso8601(value)
date += 1 if end_date
timezone.local_to_utc(Time.utc(date.year, date.month, date.day))
else
raise ArgumentError unless value.match?(/(?:Z|[+-]\d{2}:\d{2})\z/)
time = Time.iso8601(value).utc
raise ArgumentError unless time.year.between?(1900, 2100)
time
end
rescue TZInfo::AmbiguousTime, TZInfo::PeriodNotFound, Date::Error
raise ArgumentError
end
def latest_sql(row = 'v', cutoff = nil)
cutoff ||= history ? at : timestamp(params['at']) || at
keys = definition.keys.map { "newer.#{ _1 } = #{ row }.#{ _1 }" }.join(' AND ')
bound = definition.model.connection.quote(cutoff)
<<~SQL.squish
NOT EXISTS (SELECT 1 FROM #{ definition.table } newer
WHERE #{ keys } AND newer.version_no > #{ row }.version_no
AND newer.#{ definition.time_column } <= #{ bound })
SQL
end
def changed_sql(key)
column = definition.column(key)
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
end
private
def make_population
table = definition.table
@scope = @scope.from("#{ table } v").select('v.*')
keys = definition.keys.map { "previous.#{ _1 } = v.#{ _1 }" }.join(' AND ')
@scope = @scope.joins("LEFT JOIN #{ table } previous ON #{ keys } " +
'AND previous.version_no = v.version_no - 1 AND ' +
HistoryAccess.sql(definition.model, 'previous'))
@scope = @scope.where("v.#{ definition.time_column } <= ?", at)
unless history
point = timestamp(params['at']) || at
raise ArgumentError if point > at
@scope = @scope.where("v.#{ definition.time_column } <= ?", point)
.where(Arel.sql(latest_sql('v', point))).where.not('v.event_type' => 'discard')
return
end
first = timestamp(params['from'])
last = timestamp(params['through'], end_date: true)
raise ArgumentError if first && last && first > last
@scope = @scope.where('v.created_at >= ?', first) if first
if last
operator = params['through'].length == 10 ? '<' : '<='
@scope = @scope.where("v.created_at #{ operator } ?", last)
end
end
def apply_filters
['resource_id', 'user'].each do |key|
value = params[key == 'user' ? 'user_id' : key]
next if value.nil? || value == ''
raise ArgumentError unless value.match?(/\A[0-9]+\z/)
@scope = @scope.where("#{ definition.expression(key) } = ?", value.to_i)
end
apply_tags if params['tags'].present? || params['tag'].present?
conditions = QueryState.records(params, 'conditions')
raise ArgumentError unless conditions.is_a?(Array) && conditions.length <= 100
conditions.each { apply_condition(_1) }
drills = QueryState.records(params, 'drill')
raise ArgumentError unless drills.is_a?(Array) && drills.length <= 100
drills.each do |entry|
key = entry.fetch('field')
if key == 'tags'
@scope = @scope.where(snapshot_membership(entry.fetch('value')))
elsif key == 'changed_field'
raise ArgumentError unless history && definition.changes.include?(entry['value'])
@scope = @scope.where(Arel.sql(changed_sql(entry['value'])))
elsif definition.fields[key] == :datetime && entry['grain']
@scope = @scope.where(definition.expression(key) => nil) if entry['value'].nil?
if entry['value']
expression = CountsAxes.time_expression(definition.expression(key),
entry['grain'], timezone)
@scope = @scope.where("#{ expression } = ?", entry['value'])
end
else
raise ArgumentError unless definition.axes(history).include?(key)
@scope = @scope.where(definition.expression(key) => entry['value'])
end
end
end
def apply_condition(entry)
@condition ||= CountsCondition.new(self)
@scope = @condition.apply(@scope, entry)
end
def snapshot_membership(value)
kind, identifier = value.to_s.split(':', 2)
raise ArgumentError unless ['internal', 'external'].include?(kind)
raise ArgumentError unless identifier&.match?(/\A[1-9]\d*\z/)
key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
definition.model.sanitize_sql_array([
"JSON_CONTAINS(v.tags_json, JSON_OBJECT('#{ key }', CAST(? AS UNSIGNED)))",
identifier.to_i])
end
def apply_tags
raise ArgumentError unless definition.resource == 'posts'
if params['tag'].present?
@scope = @scope.where(snapshot_membership(
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
end
return unless params['tags'].present?
locale = Locale.find_by!(code: params.fetch('locale', 'ja'))
refs = PostTagSearch.parse_references(params, params['tags'].split)
raw = params['tags']
literals = PostTagSearch.literals(locale, raw, references: refs)
predicates = literals.map do |literal|
internal, external = PostTagSearch.references(locale, literal)
parts = Array(internal).map { snapshot_membership("internal:#{ _1 }") } +
Array(external).map { snapshot_membership("external:#{ _1 }") }
expression = parts.empty? ? 'FALSE' : "(#{ parts.join(' OR ') })"
literal[:negative] ? "NOT #{ expression }" : expression
end
return if predicates.empty?
any = params['match'] == 'any' || raw.split.any? { _1.downcase == 'type:or' }
@scope = @scope.where(Arel.sql("(#{ predicates.join(any ? ' OR ' : ' AND ') })"))
end
end
-207
ファイルの表示
@@ -1,207 +0,0 @@
# Read-only history queries. SQL identifiers and expressions are server-owned.
class CountsQuery
AXES = ['month', 'user'].freeze
COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'event_type', 'user', 'created_at'].freeze
COLUMNS = {
'posts' => COMMON_COLUMNS + [
'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms',
'original_created_from', 'original_created_before'],
'tags' => COMMON_COLUMNS + [
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
KEYS = [
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
'user_id', 'resource_id', 'tag', 'columns', 'sort', 'at'].freeze
attr_reader :page, :limit, :mode, :axis
def initialize(scope, resource, params, latest_expression: nil)
@table = scope.klass.table_name
@resource = resource
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
@params = params
@latest_expression = latest_expression
scope = HistoryAccess.visible(scope)
@scope = scope.except(:select, :order).joins(
"LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id")
raise ArgumentError unless (params.keys - KEYS).empty?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@mode = params.fetch('mode', 'summary')
@axis = params.fetch('axis', 'month')
raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis)
@page = integer('page', 1)
@limit = integer('limit', 20)
raise ArgumentError if limit > 100 || page > 1_000_000
columns = QueryState.list(params.fetch('columns', COLUMNS.fetch(resource).join(' ')))
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
raise ArgumentError
end
@sort = parse_sort
apply_filters
end
def relation = @scope
def count = @scope.count
def latest_expression
return @latest_expression if @latest_expression
at = timestamp('at')
cutoff = at ? "AND newer.created_at <= #{ @scope.klass.connection.quote(at) }" : ''
<<~SQL.squish
NOT EXISTS (SELECT 1 FROM #{ @table } newer
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
AND newer.version_no > #{ @table }.version_no #{ cutoff })
SQL
end
def details
@scope.select("#{ @table }.*", Arel.sql("(#{ latest_expression }) AS counts_latest"))
.order(Arel.sql(order_sql(detail_expressions, ["#{ @table }.id DESC"])))
.limit(limit).offset((page - 1) * limit)
end
def summary(include_count: true)
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
"#{ @table }.created_by_user_id"
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
sql = @scope.group(Arel.sql(expression)).select(Arel.sql(
"#{ expression } AS group_key, COUNT(*) AS record_count, " +
"#{ group_order } AS group_name")).to_sql
connection = @scope.klass.connection
source = "FROM (#{ sql }) counts_groups"
groups = include_count ? connection.select_value("SELECT COUNT(*) #{ source }") : nil
expressions = { 'group' => ['group_name'], 'count' => ['record_count'] }
order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC'])
rows = connection.select_all(
"SELECT group_key, group_name, record_count #{ source } " +
"ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }")
{ group_count: groups&.to_i,
groups: rows.map do |row|
{ key: row['group_key'],
name: axis == 'user' ? row['group_name'] : nil,
count: row['record_count'].to_i }
end }
end
private
def parse_sort
value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc')
return [] if value.empty?
values = QueryState.list(value).map { _1.split(':', -1) }
allowed = mode == 'details' ? COLUMNS.fetch(@resource) - ['thumbnail'] : ['group', 'count']
invalid = values.any? do |key, direction, *extra|
extra.any? || !allowed.include?(key) || !['asc', 'desc'].include?(direction)
end
if (values.empty? || values.length > allowed.length || invalid ||
values.map(&:first).uniq.length != values.length)
raise ArgumentError
end
values
end
# NULL always sorts last, including descending keys. The final key is unique.
def order_sql(expressions, final)
(@sort.flat_map do |key, direction|
expressions.fetch(key).flat_map do |expression|
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
end
end + final).join(', ')
end
def detail_expressions
common = { 'resource_id' => ["#{ @table }.#{ @id_column }"],
'version_no' => ["#{ @table }.version_no"],
'is_latest' => ["(#{ latest_expression })"],
'user' => ['counts_actor.name', "#{ @table }.created_by_user_id"],
'created_at' => ["#{ @table }.created_at"] }
if @resource == 'posts'
fields = [
'title', 'url', 'thumbnail_base', 'video_ms',
'original_created_from', 'original_created_before']
common.merge(fields.to_h { [_1, ["#{ @table }.#{ _1 }"]] }).merge(
'tags' => json_order('post_versions.tags_json'),
'parents' => list_order('post_versions.parent_post_ids'))
else
name = "JSON_UNQUOTE(JSON_EXTRACT(tag_versions.names, #{ locale_name_path }))"
common.merge(
'name' => ["COALESCE(#{ name }, tag_versions.name)"],
'names' => json_order('tag_versions.names'),
'category' => ['tag_versions.category'],
'aliases' => json_order(
"JSON_EXTRACT(tag_versions.names, '$.*.aliases[*]')"),
'parents' => list_order('tag_versions.parent_tag_ids'),
'deprecated' => ['tag_versions.deprecated_at'])
end
end
def locale_name_path
language = Locale.find_by(code: @params.fetch('locale', 'ja'))&.language_code || 'ja'
path = "$.#{ JSON.generate(language) }.name"
@scope.klass.connection.quote(path)
end
# Multi-valued parent cells sort by cardinality, then binary snapshot text.
def json_order(column)
[
"COALESCE(JSON_LENGTH(#{ column }), 0)",
"COALESCE(CAST(#{ column } AS CHAR), '[]') COLLATE utf8mb4_bin"]
end
def list_order(column)
[
"CASE WHEN #{ column } = '' THEN 0 ELSE " +
"1 + LENGTH(#{ column }) - LENGTH(REPLACE(#{ column }, ' ', '')) END",
"#{ column } COLLATE utf8mb4_bin"]
end
def integer(key, default = nil)
value = @params[key].presence
return default if value.nil? && !default.nil?
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
value.to_i
end
def timestamp(key)
value = @params[key].presence
return nil unless value
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
raise ArgumentError unless value.match?(pattern)
Date.iso8601(value.first(10))
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value)
end
def apply_filters
at = timestamp('at')
first = timestamp('from')
last = timestamp('before')
raise ArgumentError if first && last && first >= last
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1'))
@scope = @scope.where("#{ @table }.created_at <= ?", at) if at
@scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0'
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
if @params['resource_id'].present?
@scope = @scope.where(@table => { @id_column => integer('resource_id') })
end
if @params['user_id'].present?
user = @params['user_id'] == 'none' ? nil : integer('user_id')
@scope = @scope.where(@table => { created_by_user_id: user })
end
return unless @params['tag'].present?
raise ArgumentError unless @resource == 'posts'
id = integer('tag')
@scope = @scope.where(
"JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED))) OR " +
"JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED)))", id, id)
end
end
-146
ファイルの表示
@@ -1,146 +0,0 @@
# Server-owned version identities, attributes and field types. No client SQL is accepted.
class CountsResourceDefinition
COMMON = { 'resource_id' => :integer, 'version_no' => :integer,
'event_type' => :enum, 'user' => :integer, 'created_at' => :datetime }.freeze
DEFINITIONS = {
'posts' => [PostVersion, ['post_id'], {
'title' => :string, 'url' => :string, 'thumbnail_base' => :string,
'tags_json' => :json, 'parent_post_ids' => :string, 'video_ms' => :integer,
'original_created_from' => :datetime, 'original_created_before' => :datetime }],
'tags' => [TagVersion, ['tag_id'], {
'name' => :string, 'names' => :json, 'category' => :enum, 'aliases' => :string,
'parent_tag_ids' => :string, 'deprecated_at' => :datetime }],
'nico_tags' => [NicoTagVersion, ['tag_id'], {
'name' => :string, 'linked_tags' => :string }],
'tag_descriptions' => [TagDescriptionVersion, ['tag_id', 'language_code'], {
'language_code' => :enum, 'body' => :string,
'source_language_code' => :enum, 'source_version_no' => :integer }],
'tag_groups' => [TagGroupVersion, ['tag_group_id'], {
'name' => :string, 'description' => :string,
'members' => :json, 'placements' => :json }],
'materials' => [MaterialVersion, ['material_id'], {
'url' => :string, 'parent_id' => :integer, 'tag_id' => :integer,
'tag_name' => :string, 'tag_category' => :enum,
'discarded_at' => :datetime, 'updated_at' => :datetime,
'updated_by_user_id' => :integer, 'export_paths_json' => :json,
'file_blob_id' => :integer, 'file_filename' => :string,
'file_content_type' => :string, 'file_byte_size' => :integer,
'file_checksum' => :string, 'file_sha256' => :string,
'source_kind' => :enum, 'source_uri' => :string, 'source_path' => :string,
'source_file_id' => :string, 'normalized_source_key' => :string }] }.freeze
OPERATORS = {
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], boolean: ['eq', 'ne'],
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
interval: ['range'] }.freeze
LABELS = {
'resource_id' => 'counts:columns.resourceId', 'version_no' => 'counts:columns.version',
'is_latest' => 'counts:columns.latest', 'event_type' => 'counts:eventType',
'user' => 'history:actor', 'created_at' => 'counts:columns.changedAt',
'thumbnail' => 'counts:columns.thumbnailDisplay',
'thumbnail_base' => 'counts:columns.thumbnailURL',
'title' => 'counts:columns.postTitle', 'url' => 'counts:columns.postURL',
'tags' => 'counts:columns.tags', 'tags_json' => 'counts:columns.tags',
'video_ms' => 'shared:labels.duration', 'name' => 'counts:columns.name',
'names' => 'counts:columns.names', 'category' => 'common:categoryLabel',
'aliases' => 'tags:aliases', 'deprecated' => 'counts:columns.deprecated',
'original_created_from' => 'counts:columns.originalFrom',
'original_created_before' => 'counts:columns.originalBefore',
'language_code' => 'counts:columns.language' }.freeze
CHILD_LABELS = {
'id' => 'counts:columns.resourceId', 'versionNo' => 'history:version',
'kind' => 'counts:columns.kind', 'name' => 'history:name',
'category' => 'common:categoryLabel', 'sections' => 'counts:columns.sections',
'names' => 'counts:columns.names', 'aliases' => 'tags:aliases',
'languageCode' => 'counts:columns.language',
'scriptCode' => 'history:nameChanges.script',
'autoGenerated' => 'history:nameChanges.automatic',
'tagId' => 'counts:attributes.tag_id', 'tagGroupId' => 'counts:resources.tag_groups',
'sectionName' => 'counts:attributes.section_name',
'description' => 'counts:attributes.description',
'children' => 'counts:attributes.children', 'profile' => 'counts:attributes.profile',
'path' => 'counts:attributes.path' }.freeze
attr_reader :model, :keys, :fields, :resource
def initialize(resource)
@resource = resource
@model, @keys, attributes = DEFINITIONS.fetch(resource) { raise ArgumentError }
@fields = COMMON.merge(attributes)
end
def table = model.table_name
def time_column = 'created_at'
def column(key)
raise ArgumentError unless fields.key?(key)
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
end
def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
def axes(history)
fields.reject { |_key, type| type == :json }.keys +
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
end
def metadata
fields.map do |key, type|
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
operators: OPERATORS.fetch(type, []) }
end
end
def field_label(key) = LABELS.fetch(key, "counts:attributes.#{ key }")
def output_fields
content = case resource
when 'posts'
['thumbnail', 'title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
'original_created_from', 'original_created_before']
when 'tags' then ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
else fields.keys - COMMON.keys
end
keys = ['resource_id', 'version_no', 'is_latest'] + (content - ['thumbnail']) +
['event_type', 'user', 'created_at']
keys.unshift('thumbnail') if content.include?('thumbnail')
keys.map do |key|
label = if key == 'parents'
resource == 'tags' ? 'tags:parents' : 'shared:labels.parentPosts'
else field_label(key)
end
field = { key:, label: }
children = child_fields(key)
field[:children] = children if children.any?
field
end
end
def child_fields(key, depth = 4)
keys = case key
when 'tags' then ['id', 'versionNo', 'kind', 'name', 'category', 'sections', 'names']
when 'names' then ['languageCode', 'scriptCode', 'name', 'aliases', 'autoGenerated']
when 'aliases'
resource == 'tags' ? ['languageCode', 'name', 'scriptCode'] : ['name', 'scriptCode']
when 'parents' then resource == 'tags' ? ['id', 'name'] : ['id']
when 'members' then ['tagId', 'tagGroupId', 'versionNo']
when 'export_paths_json' then ['profile', 'path']
when 'placements', 'children'
scalar = ['sectionName', 'description', 'tagId', 'tagGroupId']
scalar + (depth > 0 ? ['children'] : [])
else []
end
keys.map do |child|
label = key == 'members' && child == 'tagId' ?
'counts:columns.resourceId' : CHILD_LABELS.fetch(child)
field = { key: child, label: }
children = child_fields(child, child == 'children' ? depth - 1 : depth)
# Language is attached to top-level aliases, not to aliases inside a language entry.
children = children.reject { _1[:key] == 'languageCode' } if child == 'aliases'
field[:children] = children if children.any?
field
end
end
def filter_metadata = @filter_metadata ||= CountsCondition.fields(self)
end
-370
ファイルの表示
@@ -1,370 +0,0 @@
# Restores one recorded version per resource before applying record filters.
class CountsStateQuery
KEYS = ['locale', 'resource', 'mode', 'at', 'from', 'before', 'grain',
'kind', 'tag', 'page', 'limit', 'sort', 'axis', 'user_id', 'resource_id'].freeze
MAX_PERIODS = 366
attr_reader :at
def initialize(params, language_code:)
raise ArgumentError unless (params.keys - KEYS).empty?
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
@params = params
@connection = ApplicationRecord.connection
@language = language_code
@resource = params.fetch('resource', 'posts')
@mode = params.fetch('mode', 'summary')
@inclusive = ['summary', 'details'].include?(@mode)
@axis = params.fetch('axis', @resource == 'posts' ? 'tags' : 'month')
@kind = params.fetch('kind', 'internal')
@grain = params.fetch('grain', 'month')
raise ArgumentError unless ['posts', 'tags'].include?(@resource)
raise ArgumentError unless ['summary', 'details', 'state', 'trend', 'snapshot'].include?(@mode)
raise ArgumentError unless ['internal', 'external', 'all'].include?(@kind)
raise ArgumentError unless ['day', 'month', 'year'].include?(@grain)
raise ArgumentError unless ['tags', 'month', 'user'].include?(@axis)
if @mode == 'summary' && @axis == 'tags' && @resource != 'posts'
raise ArgumentError
end
@page = integer('page', 1)
@limit = integer('limit', 20)
@tag = integer('tag') if params['tag'].present?
raise ArgumentError if @limit > 100 || @page > 1_000_000
raise ArgumentError if @tag && @kind == 'all'
raise ArgumentError if @mode == 'trend' && !@tag
@now = Time.current.utc
@at = timestamp('at') || @now
raise ArgumentError if @at > @now
end
def call(metadata: true)
@metadata = metadata
# Keep totals, rows, and detail pages on one repeatable-read database snapshot.
ApplicationRecord.transaction do
unless @history_loaded
@post_start = HistoryAccess.visible(PostVersion.all).minimum(:created_at)
@tag_start = HistoryAccess.visible(TagVersion.all).minimum(:created_at)
@history_loaded = true
end
result = { population_count: HistoryAccess.population_count(
@resource == 'posts' ? PostVersion : TagVersion), page: @page, limit: @limit, at: @at.iso8601(6),
coverage: { basis: 'recorded_snapshots', exact: false,
post_history_from: @post_start&.iso8601(6),
tag_history_from: @tag_start&.iso8601(6) } }
case @mode
when 'trend'
result.merge(trend)
when 'summary'
result.merge(metadata ? { totals: totals } : {}).merge(summary)
when 'details', 'snapshot'
result.merge(metadata ? { totals: totals } : {}).merge(details)
else
result.merge(metadata ? { totals: totals } : {}).merge(listing)
end
end
end
def each_page
return enum_for(:each_page) unless block_given?
@page = 1
first = call
yield first
total = ['summary', 'state', 'trend'].include?(@mode) ? first[:group_count] : first[:count]
while @page * @limit < total
@page += 1
yield call(metadata: false)
end
end
def validate_query!
filtered_scope(@resource)
case @mode
when 'summary'
if @axis == 'tags'
order_sql({ 'name' => 'name', 'count' => 'post_count', 'tag_id' => 'tag_id',
'kind' => 'kind', 'deprecated' => 'deprecated_at' }, 'count:desc', [])
else
record_query('summary')
end
when 'details', 'snapshot'
record_query('details')
end
self
end
private
def quote(value) = @connection.quote(value)
def integer(key, default = nil)
value = @params[key]
return default if value.blank? && default
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
value.to_i
end
def timestamp(key)
value = @params[key].presence
return nil unless value
pattern = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?Z\z/
raise ArgumentError unless value.match?(pattern)
Date.iso8601(value.first(10))
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value).utc
end
# Version numbers order equal-timestamp writes. Do not filter tags before this selection.
def latest_condition(table, row, boundary)
key = table == 'post_versions' ? 'post_id' : 'tag_id'
<<~SQL.squish
#{ row }.created_at #{ @inclusive ? '<=' : '<' } #{ boundary } AND NOT EXISTS (
SELECT 1 FROM #{ table } newer
WHERE newer.#{ key } = #{ row }.#{ key }
AND newer.created_at #{ @inclusive ? '<=' : '<' } #{ boundary }
AND newer.version_no > #{ row }.version_no)
SQL
end
def state_sql(table, boundary, live: false)
model = { 'post_versions' => PostVersion, 'tag_versions' => TagVersion,
'nico_tag_versions' => NicoTagVersion }.fetch(table)
sql = "SELECT v.* FROM #{ table } v WHERE #{ latest_condition(table, 'v', boundary) } " +
"AND (#{ HistoryAccess.sql(model, 'v') })"
live ? "#{ sql } AND v.event_type <> 'discard'" : sql
end
def membership(row)
key = @kind == 'external' ? 'external_tag_id' : 'tag_id'
"JSON_CONTAINS(#{ row }.tags_json, JSON_OBJECT('#{ key }', #{ @tag }))"
end
# Deprecation keeps a tag in existence. Only an explicit discard removes it.
def tag_visible(boundary)
return '1 = 1' if @kind == 'external'
<<~SQL.squish
NOT EXISTS (SELECT 1 FROM tag_versions t
WHERE t.tag_id = #{ @tag } AND t.event_type = 'discard'
AND #{ latest_condition('tag_versions', 't', boundary) })
SQL
end
def ctes
boundary = quote(@at)
<<~SQL
WITH post_at AS (#{ filtered_scope('posts').to_sql }),
tag_at AS (#{ state_sql('tag_versions', boundary) }),
nico_at AS (#{ state_sql('nico_tag_versions', boundary) }),
pairs AS (
SELECT DISTINCT p.post_id,
CASE WHEN entry.tag_id IS NULL THEN 'external' ELSE 'internal' END AS kind,
COALESCE(entry.tag_id, entry.external_tag_id) AS tag_id
FROM post_at p
JOIN JSON_TABLE(CAST(p.tags_json AS JSON), '$[*]' COLUMNS (
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY ERROR ON ERROR,
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY ERROR ON ERROR
)) entry
LEFT JOIN tag_at t ON t.tag_id = entry.tag_id
WHERE COALESCE(entry.tag_id, entry.external_tag_id) IS NOT NULL
AND (t.event_type IS NULL OR t.event_type <> 'discard')
)
SQL
end
def totals
row = @connection.select_one(<<~SQL)
#{ ctes }
SELECT (SELECT COUNT(*) FROM post_at) AS posts,
(SELECT COUNT(*) FROM (#{ filtered_scope('tags').to_sql }) counted_tags) AS tags,
(SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard'
AND deprecated_at IS NOT NULL) AS deprecated_tags,
(SELECT COUNT(*) FROM pairs) AS assignments,
(SELECT COUNT(*) FROM pairs WHERE kind = 'external') AS external_assignments
SQL
row.transform_values!(&:to_i)
row.merge('posts' => available?(@post_start, @at) ? row['posts'] : nil,
'assignments' => available?(@post_start, @at) ? row['assignments'] : nil,
'external_assignments' => available?(@post_start, @at) ?
row['external_assignments'] : nil,
'tags' => available?(@tag_start, @at) ? row['tags'] : nil,
'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil)
end
def available?(first, boundary)
first && (@inclusive ? first <= boundary : first < boundary)
end
def listing
path = quote("$.#{ JSON.generate(@language) }.name")
catalog = <<~SQL
, tag_counts AS (
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
FROM pairs membership GROUP BY membership.kind, membership.tag_id
), catalog AS (
SELECT 'internal' AS kind, tag_id,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(names, #{ path })), name) AS name,
deprecated_at FROM tag_at WHERE event_type <> 'discard'
UNION ALL
SELECT tc.kind, tc.tag_id, external_version.name, NULL AS deprecated_at
FROM tag_counts tc
LEFT JOIN tag_at internal_version
ON tc.kind = 'internal' AND internal_version.tag_id = tc.tag_id
LEFT JOIN nico_at external_version
ON tc.kind = 'external' AND external_version.tag_id = tc.tag_id
WHERE tc.kind = 'external' OR internal_version.tag_id IS NULL
)
SQL
filters = []
filters << "c.kind = #{ quote(@kind) }" unless @kind == 'all'
filters << "c.tag_id = #{ @tag }" if @tag
where = filters.any? ? "WHERE #{ filters.join(' AND ') }" : ''
source = 'FROM catalog c LEFT JOIN tag_counts n ON n.kind = c.kind AND n.tag_id = c.tag_id'
prefix = "#{ ctes } #{ catalog }"
total = if @metadata
@connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i
end
order = order_sql({ 'name' => 'c.name', 'count' => 'COALESCE(n.post_count, 0)',
'tag_id' => 'c.tag_id', 'kind' => 'c.kind',
'deprecated' => 'c.deprecated_at' }, 'count:desc',
['c.kind ASC', 'c.tag_id ASC'])
rows = @connection.select_all(<<~SQL)
#{ prefix }
SELECT c.kind, c.tag_id, c.name, c.deprecated_at,
COALESCE(n.post_count, 0) AS post_count #{ source } #{ where }
ORDER BY #{ order } LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
SQL
{ count: total, group_count: total, state_rows: rows.map do |row|
{ kind: row['kind'], tag_id: row['tag_id'], name: row['name'],
deprecated: row['deprecated_at']&.iso8601,
count: available?(@post_start, @at) ? row['post_count'].to_i : nil,
at: @at.iso8601(6) }
end }
end
# All record filters apply after selecting the latest recorded version at the point.
def restored_scope(resource)
model = resource == 'posts' ? PostVersion : TagVersion
table = model.table_name
scope = model.where(Arel.sql(latest_condition(table, table, quote(@at))))
.where.not(event_type: 'discard')
if @tag
scope = if resource == 'posts'
scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }")
elsif @kind == 'internal'
scope.where(tag_id: @tag)
else
scope.none
end
end
scope
end
def filtered_scope(resource)
params = @params.slice('from', 'before', 'user_id', 'resource_id')
.merge('mode' => 'details', 'latest' => '0', 'sort' => '')
CountsQuery.new(restored_scope(resource), resource, params,
latest_expression: '1').relation
end
def record_query(mode)
params = @params.slice('locale', 'sort', 'page', 'limit')
.merge('mode' => mode, 'latest' => '0', 'axis' => @axis == 'tags' ? 'month' : @axis,
'page' => @page.to_s, 'limit' => @limit.to_s)
CountsQuery.new(filtered_scope(@resource), @resource, params, latest_expression: '1')
end
def summary
return listing if @axis == 'tags'
query = record_query('summary')
{ count: @metadata ? query.count : nil }.merge(query.summary(include_count: @metadata))
end
def details
query = record_query('details')
{ count: @metadata ? query.count : nil,
versions: CountsVersionRepr.many(query.details, resource: @resource,
language_code: @language, at: @at,
inclusive: @inclusive) }
end
def next_boundary(time)
case @grain
when 'day' then time.beginning_of_day + 1.day
when 'month' then time.beginning_of_month.next_month
else time.beginning_of_year.next_year
end
end
def periods
@before = timestamp('before') || @now.beginning_of_month
@from = timestamp('from') || @before.prev_year
raise ArgumentError if @from >= @before || @before > @now
rows = []
boundary = next_boundary(@from)
while boundary <= @before
raise ArgumentError if rows.length >= MAX_PERIODS
format = { 'day' => '%Y-%m-%d', 'month' => '%Y-%m', 'year' => '%Y' }.fetch(@grain)
rows << [boundary, (boundary - 1.second).strftime(format)]
boundary = next_boundary(boundary)
end
rows
end
def trend
points = periods
metadata = { count: points.length, group_count: points.length, state_rows: [],
from: @from.iso8601, before: @before.iso8601, grain: @grain }
return metadata if points.empty?
values = points.map do |boundary, period|
"SELECT CAST(#{ quote(boundary) } AS DATETIME(6)) AS boundary, " +
"#{ quote(period) } AS period"
end.join(' UNION ALL ')
order = order_sql({ 'period' => 'boundary', 'count' => 'post_count' },
'period:asc', ['boundary ASC'])
known = @post_start ? "periods.boundary > #{ quote(@post_start) }" : 'FALSE'
rows = @connection.select_all(<<~SQL)
WITH periods AS (#{ values }), series AS (
SELECT periods.boundary, periods.period,
CASE WHEN #{ known } THEN COUNT(DISTINCT p.post_id) ELSE NULL END AS post_count
FROM periods
LEFT JOIN post_versions p ON p.event_type <> 'discard'
AND #{ membership('p') }
AND #{ latest_condition('post_versions', 'p', 'periods.boundary') }
AND #{ tag_visible('periods.boundary') }
GROUP BY periods.boundary, periods.period
)
SELECT * FROM series ORDER BY #{ order }
LIMIT #{ @limit } OFFSET #{ (@page - 1) * @limit }
SQL
metadata.merge(state_rows: rows.map do |row|
{ kind: @kind, tag_id: @tag, name: nil, period: row['period'],
at: row['boundary'].utc.iso8601(6), count: row['post_count']&.to_i }
end)
end
def order_sql(expressions, default, final)
raw = @params.fetch('sort', default)
sorts = raw.empty? ? [] : QueryState.list(raw).map { _1.split(':', -1) }
raise ArgumentError if sorts.map(&:first).uniq.length != sorts.length
raise ArgumentError if sorts.length > expressions.length
order = sorts.flat_map do |key, direction, *extra|
if extra.any? || !expressions.key?(key) || !['asc', 'desc'].include?(direction)
raise ArgumentError
end
expression = expressions.fetch(key)
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
end
(order + final).join(', ')
end
end
-329
ファイルの表示
@@ -1,329 +0,0 @@
require 'csv'
# Export uses the exact same population and grouped cells as the interactive table.
class CountsTableExport
EXTRA = ['format', 'columns', 'headers', 'labels', 'children', 'child_sort',
'order', 'nested_json', 'summary_columns', 'basis', 'axis', 'latest'].freeze
attr_reader :format, :resource
def initialize(params)
QueryState.validate!(params, CountsTableQuery::KEYS + EXTRA + ['measures'])
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
params = CountsDefaults.for(params).merge(params)
unless params.key?('children')
params = params.merge('children' => CountsExportProjection.default_children(
params.fetch('resource', 'posts')).join(' '))
end
@params = params
@labels = JSON.parse(params.fetch('labels', '{}'))
raise ArgumentError unless @labels.is_a?(Hash)
@selected = params.key?('children') ? QueryState.list(params['children']) : nil
@order = QueryState.list(params.fetch('order', ''))
@child_sort = QueryState.list(params.fetch('child_sort', '')).map { _1.split(':', -1) }
@child_sort.each do |path, direction, *extra|
raise ArgumentError if extra.any? || !['asc', 'desc'].include?(direction)
raise ArgumentError unless path&.match?(/\A[a-zA-Z_]+(?:\.[a-zA-Z]+)*\/[a-zA-Z]+\z/)
end
@format = params.fetch('format', 'json')
@resource = params.fetch('resource', 'posts')
raise ArgumentError unless ['json', 'csv'].include?(format)
@query = CountsTableQuery.new(params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
key == 'measures' || QueryState.valid_key?(key) }
.merge('page' => '1', 'limit' => '100'))
@summary = @query.mode == 'summary'
default_columns = @summary ? @query.axes.all.map { _1.fetch('field') } + ['count'] :
CountsExportProjection.default_columns(resource)
@columns = QueryState.list(params.fetch('columns', default_columns.join(' ')))
default_headers = @columns.map { @labels.fetch(_1, _1) }
@headers = JSON.parse(params.fetch('headers', default_headers.to_json))
raise ArgumentError unless @headers.is_a?(Array) && @headers.all? { _1.is_a?(String) }
if @summary && @headers.any? && @headers.length != @query.axes.all.length + 1
raise ArgumentError
end
unless @summary
if ['posts', 'tags'].include?(resource)
@projection = CountsExportProjection.new(params, resource:, summary: false, tags: false)
else
allowed = @query.population.definition.fields.keys + ['is_latest']
raise ArgumentError if (@columns - allowed).any?
raise ArgumentError unless @columns.length == @headers.length && @columns.uniq == @columns
end
end
end
def body(progress: nil)
Enumerator.new do |output|
ApplicationRecord.transaction(isolation: :repeatable_read) do
@language_names = Language.pluck(:code, :name).to_h
processed = 0
started = false
first = true
@query.each_page do |page|
total = @summary ? nil : page[:count]
progress&.call(processed, total)
unless started
metadata = page.except(:versions, :cells, :axis_labels, :column_values,
:row_headers, :column_headers, :pivot_totals).merge(
resource:, record_unit: @summary && format == 'json' ? 'pivot_cell' : 'row',
body_row_totals: 'once_per_logical_group', page_grand_total_repeated: false,
filters: @params.select { |key, _value| CountsTableQuery::KEYS.include?(key) ||
key == 'measures' || QueryState.valid_key?(key) },
fields: @query.population.definition.metadata,
columns: @columns, column_labels: @headers,
order: @params['order'], children: @params['children'],
sort: @params.fetch('sort', ''), child_sort: @params['child_sort'])
output << (format == 'json' ? "{\"metadata\":#{ metadata.to_json },\"data\":[" : "\uFEFF")
if format == 'csv'
headings = @summary ? pivot_headers(page) : [@headers]
headings.each do |headers|
output << CSV.generate_line(headers.map { safe(_1) }, row_sep: "\r\n")
end
end
started = true
end
if @summary
pivot_rows(page).each do |header|
records = page.fetch(:column_headers).map { pivot_cell(header, _1, page) }
if format == 'csv'
labels = pivot_row_labels(header, page)
values = labels + records.map { _1[:count] }
output << CSV.generate_line(values.map { safe(_1) }, row_sep: "\r\n")
processed += records.length
progress&.call(processed, nil)
else
records.each do |record|
output << ',' unless first
output << record.to_json
first = false
processed += 1
progress&.call(processed, nil)
end
end
end
next
end
page[:versions].each do |row|
record = project(row)
if format == 'json'
output << ',' unless first
output << record.to_json
else
values = if @summary
row[:values].each_with_index.map do |value, index|
summary_value(value, index, page)
end + [row[:count]]
elsif @projection
@projection.csv_record(record, row)
else
record.map { |key, value| csv_logical(key, value) }
end
output << CSV.generate_line(values.map { safe(_1) }, row_sep: "\r\n")
end
first = false
processed += 1
progress&.call(processed, total)
end
end
progress&.call(processed, processed)
output << ']}' if format == 'json'
end
end
end
private
def pivot_rows(page)
page.fetch(:row_headers).reject do |header|
header[:kind] == 'grand_total' && @query.page * @query.limit < page[:group_count]
end
end
def total_label(header)
key = header[:kind] == 'grand_total' ?
(@query.axes.all.length == 1 ? 'singleTotal' : 'grandTotal') : 'subtotal'
@labels.fetch(key, '')
end
def pivot_row_labels(header, page)
values = header[:values].each_with_index.map { |value, index|
summary_value(value, index, page)
}
if header[:kind] == 'data' && @previous_pivot_row&.fetch(:kind) == 'data'
values.each_index do |index|
if header[:values].first(index + 1) == @previous_pivot_row[:values].first(index + 1)
values[index] = ''
end
end
end
@previous_pivot_row = header
values << total_label(header) unless header[:kind] == 'data'
leading = [@query.axes.rows.length, @query.axes.columns.any? ? 1 : 0].max
Array.new(leading) { values[_1] || '' }
end
def pivot_headers(page)
row_count = @query.axes.rows.length
cols = @query.axes.columns
leading = [row_count, cols.any? ? 1 : 0].max
columns = page.fetch(:column_headers)
headings = Array.new([cols.length, 1].max) do |level|
prefix = Array.new(leading) { level.zero? ? @headers[_1].to_s : '' }
prefix = [''] if row_count.zero? && cols.any?
prefix + columns.each_with_index.map do |header, index|
values = header[:values]
if cols.empty?
@labels.fetch('count', '')
elsif level == values.length && header[:kind] != 'data'
total_label(header)
elsif level < values.length
previous = index.positive? ? columns[index - 1][:values] : nil
if level < cols.length - 1 && previous && previous.first(level + 1) == values.first(level + 1)
''
else
summary_value(values[level], row_count + level, page)
end
else
''
end
end
end
return headings if cols.empty?
cols.each_index.flat_map do |level|
prefix = Array.new(leading, '')
prefix = headings.first.first(leading) if level.zero?
names = columns.each_with_index.map do |header, index|
next '' if header[:values].length < level
previous = index.positive? ? columns[index - 1][:values] : nil
if (previous && previous.length >= level &&
previous.first(level) == header[:values].first(level))
''
else
@headers[row_count + level].to_s
end
end
headings[level] = Array.new(leading, '') + headings[level].drop(leading) if level.zero?
[prefix + names, headings[level]]
end
end
def pivot_cell(row, column, page)
@pivot_lookup ||= {}
# Discard the previous page's lookup; memory remains bounded by one page and its rollups.
unless @pivot_page.equal?(page)
@pivot_page = page
@pivot_lookup = page.fetch(:cells).to_h do |cell|
values = cell[:values]
[[values.first(@query.axes.rows.length), values.drop(@query.axes.rows.length)], cell[:count]]
end
page.fetch(:pivot_totals).each { @pivot_lookup[[_1[:row], _1[:column]]] = _1[:count] }
end
kinds = [row[:kind], column[:kind]]
kind = if kinds.include?('grand_total')
'grand_total'
elsif kinds.include?('subtotal')
'subtotal'
else
'data'
end
{ row: row[:values], column: column[:values], kind:,
row_kind: row[:kind], column_kind: column[:kind],
row_depth: row[:values].length, column_depth: column[:values].length,
count: @pivot_lookup.fetch([row[:values], column[:values]], 0) }
end
def summary_value(value, index, page)
field = @query.axes.all.fetch(index).fetch('field')
return @labels.fetch('bot', '') if field == 'user' && value.nil?
names = page[:axis_labels].fetch("a#{ index }", {})
name = field == 'user' ? names[value.to_s] : names.fetch(value.to_s, value)
if field == 'user'
name = @labels.fetch('notRegistered', '') if name.nil? || name == ''
pattern = @labels.fetch('actorFormat', '{{name}} (#{{id}})')
return pattern.gsub(/\{\{(name|id)\}\}/) do
Regexp.last_match(1) == 'name' ? name.to_s : value.to_s
end
end
@labels.fetch("#{ field }:#{ value }", name)
end
def project(row)
return { row: row[:values].first(@query.axes.rows.length),
column: row[:values].drop(@query.axes.rows.length), count: row[:count] } if @summary
return @projection.project(row) if @projection
@columns.to_h do |column|
key = column == 'user' ? 'created_by_user' : column
value = row[key]
value = project_nested(value, key) if ['members', 'placements', 'export_paths_json'].include?(key)
[key, value]
end
end
def camel(key)
key.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
end
def project_nested(value, path)
rows = path == 'export_paths_json' ? (value || {}).map { |profile, target|
{ 'profile' => profile, 'path' => target }
} : Array(value)
sorts = @child_sort.filter_map do |key, direction|
parent, field = key.split('/', 2)
[field, direction] if parent == path
end
rows = rows.each_with_index.sort do |(first, a), (last, b)|
compared = 0
sorts.each do |field, direction|
left = first.find { |key, _| camel(key) == field }&.last
right = last.find { |key, _| camel(key) == field }&.last
compared = if left.nil? || right.nil?
left.nil? == right.nil? ? 0 : (left.nil? ? 1 : -1)
else
(left.to_s <=> right.to_s) * (direction == 'asc' ? 1 : -1)
end
break unless compared.zero?
end
compared.zero? ? a <=> b : compared
end.map(&:first)
rows.map do |row|
fields = row.keys.select { !@selected || @selected.include?("#{ path }.#{ camel(_1) }") }
ordered = (@order + fields.map { "#{ path }.#{ camel(_1) }" }).uniq
.filter_map { |item| fields.find { "#{ path }.#{ camel(_1) }" == item } }
ordered.to_h do |key|
item = row[key]
item = project_nested(item, "#{ path }.#{ camel(key) }") if item.is_a?(Array)
[key, item]
end
end
end
def csv_logical(key, value)
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
return value ? @labels.fetch('latest', '') : '' if key == 'is_latest'
if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
return @query.population.timezone.utc_to_local(value.utc).iso8601
end
if key == 'language_code' || key == 'source_language_code'
return @language_names[value]
end
if value.is_a?(Hash)
return value.to_h { |field, item| [@labels.fetch(camel(field.to_s), ''), csv_logical(field, item)] }
end
return value.map { csv_logical(key, _1) } if value.is_a?(Array)
@labels.fetch("#{ key }:#{ value }", value)
end
def safe(value)
return value if value.is_a?(Numeric)
text = value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s
text.match?(/\A\s*[=+@-]/) ? "'#{ text }" : text
end
end
-345
ファイルの表示
@@ -1,345 +0,0 @@
# Common entrypoint for six version resources; legacy endpoints keep their contracts.
class CountsTableQuery
MAX_COLUMNS = 100
class TooManyColumns < ArgumentError; end
KEYS = ['resource', 'definition', 'mode', 'at', 'execution_at', 'from', 'through',
'timezone', 'rows', 'cols', 'conditions', 'drill', 'tags', 'tag_refs', 'match',
'tag', 'kind', 'resource_id', 'user_id', 'page', 'limit', 'sort', 'locale', 'measures', 'column_order'].freeze
attr_reader :population, :axes, :page, :limit, :mode
def initialize(params)
QueryState.validate!(params, KEYS + ['measures'])
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
params = CountsDefaults.for(params).slice(*KEYS).merge(params)
@params = params
@population = CountsPopulation.new(params)
@definition = population.definition
@connection = @definition.model.connection
@axes = CountsAxes.new(population)
@page = Integer(params.fetch('page', '1'), 10)
@limit = Integer(params.fetch('limit', '20'), 10)
@mode = params.fetch('mode', 'summary')
raise ArgumentError unless page.between?(1, 1_000_000) && limit.between?(1, 100)
raise ArgumentError unless ['summary', 'details'].include?(mode)
@sort = QueryState.list(params.fetch('sort', '')).map { _1.split(':', -1) }
allowed = mode == 'summary' ? axes.all.each_index.map { "a#{ _1 }" } + ['count'] :
@definition.fields.keys + ['is_latest'] +
(@definition.resource == 'posts' ? ['tags', 'parents'] : []) +
(@definition.resource == 'tags' ? ['parents', 'deprecated'] : [])
raise ArgumentError if @sort.map(&:first).uniq.length != @sort.length
@sort.each do |key, direction, *extra|
measure = mode == 'summary' && key&.match?(/\Am[0-9]{1,2}\z/)
measure_values(key) if measure
if extra.any? || (!allowed.include?(key) && !measure) || !['asc', 'desc'].include?(direction)
raise ArgumentError
end
end
end
def call
ApplicationRecord.transaction do
result = metadata.merge(mode == 'summary' ? summary : details)
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
# A scalar result has one row regardless of private attributes or predicates.
public_count = result[:excluded_count].zero? || (mode == 'summary' && axes.rows.empty?)
result.merge(visible_output_count: visible,
output_count: public_count ? visible : nil,
output_count_scope: public_count ?
'before_authorisation' : 'unavailable_private_history',
output_count_unit: 'table_body_rows',
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
result[:group_count] > limit)
end
end
def metadata
@population_counts ||= HistoryAccess.population_counts(@definition.model)
@population_counts.merge(
population_count_scope: 'all_recorded_versions',
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
definition: population.history ? 'history' : 'latest',
timezone: population.timezone.identifier, rows: axes.rows, cols: axes.columns,
fields: @definition.metadata,
count: population.scope.except(:select).optimizer_hints('MAX_EXECUTION_TIME(10000)').count)
end
def summary
expressions = axes.expressions
selected = expressions.each_with_index.map do |expression, index|
"#{ expression } AS a#{ index }"
end
source = population.scope
source = source.joins(axes.joins) unless axes.joins.empty?
sql = if expressions.empty?
source.except(:select).select('COUNT(DISTINCT v.id) AS count').to_sql
else
source.except(:select).group(*expressions.map { Arel.sql(_1) })
.select(Arel.sql((selected + ['COUNT(DISTINCT v.id) AS count']).join(', ')))
.to_sql
end
prefix = "WITH cells AS (#{ sql })"
column_values = limited_columns(prefix)
row_keys = axes.rows.each_index.map { "a#{ _1 }" }
# Page whole row tuples so a pivot row is never split across display pages.
row_selection = row_keys.empty? ? '1 AS singleton' : row_keys.join(', ')
row_group = row_keys.empty? ? 'GROUP BY singleton' : "GROUP BY #{ row_keys.join(', ') }"
measure_joins = []
extras = []
row_sort = @sort.each_with_index.filter_map do |(key, direction), index|
if key == 'count' || key.match?(/\Am[0-9]+\z/)
values = key == 'count' ? [] : measure_values(key)
relation = measure_relation(values)
matches = row_keys.map { "grouped.#{ _1 } <=> sort#{ index }.#{ _1 }" }
condition = matches.empty? ? 'TRUE' : matches.join(' AND ')
measure_joins << "LEFT JOIN (#{ relation }) sort#{ index } ON #{ condition }"
extras << "sort#{ index }.count AS sort#{ index }"
"sort#{ index } IS NULL ASC, sort#{ index } #{ direction.upcase }"
elsif row_keys.include?(key)
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
end
end
order_rows = row_sort + row_keys.map { "#{ _1 } IS NULL, #{ _1 }" }
order_rows = ['singleton ASC'] if order_rows.empty?
grouped = "SELECT #{ row_selection } FROM cells #{ row_group }"
selection = (['grouped.*'] + extras).join(', ')
group_sql = "SELECT #{ selection } FROM (#{ grouped }) grouped #{ measure_joins.join(' ') }"
total = @connection.select_value(<<~SQL).to_i
#{ prefix }
SELECT /*+ MAX_EXECUTION_TIME(10000) */ COUNT(*) FROM (#{ group_sql }) row_groups
SQL
join = row_keys.map { "cells.#{ _1 } <=> page_rows.#{ _1 }" }.join(' AND ')
join = 'TRUE' if row_keys.empty?
order_cols = @sort.filter_map do |key, direction|
next unless key.match?(/\Aa[0-9]+\z/) && !row_keys.include?(key)
"cells.#{ key } #{ direction.upcase }"
end
order = ['page_rows.position'] + order_cols +
axes.all.each_index.map { "cells.a#{ _1 }" }
records = @connection.select_all(<<~SQL)
#{ prefix }, row_groups AS (#{ group_sql }),
numbered AS (SELECT row_groups.*,
ROW_NUMBER() OVER (ORDER BY #{ order_rows.join(', ') }) AS position FROM row_groups),
page_rows AS (SELECT * FROM numbered
WHERE position > #{ (page - 1) * limit } AND position <= #{ page * limit })
SELECT /*+ MAX_EXECUTION_TIME(10000) */ cells.* FROM cells JOIN page_rows ON #{ join }
ORDER BY #{ order.join(', ') }
SQL
next_row = @connection.select_one(<<~SQL)
#{ prefix }, row_groups AS (#{ group_sql }),
numbered AS (SELECT row_groups.*,
ROW_NUMBER() OVER (ORDER BY #{ order_rows.join(', ') }) AS position FROM row_groups)
SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM numbered
WHERE position = #{ page * limit + 1 }
SQL
row_values = records.map { |record| row_keys.map { record[_1] } }.uniq
row_depths = CountsPivotLayout.depths(row_keys, @sort)
column_keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
column_sort = @sort
if @params.key?('column_order') && column_keys.any? &&
!(@sort.first&.first == 'count' || @sort.first&.first&.match?(/\Am[0-9]+\z/))
column_sort = [[column_keys.first, 'asc']] + @sort
end
column_depths = CountsPivotLayout.depths(column_keys, column_sort, measures: false)
row_headers = CountsPivotLayout.headers(row_values, row_depths,
next_values: next_row && row_keys.map { next_row[_1] }, grand: axes.rows.any?)
if axes.rows.empty? && axes.columns.any?
row_headers = [{ values: [], kind: 'grand_total' }]
end
column_headers = CountsPivotLayout.headers(column_values, column_depths,
grand: axes.columns.any?)
if axes.columns.empty? && axes.rows.any?
column_headers = [{ values: [], kind: 'grand_total' }]
end
totals = CountsPivotTotals.new(population, axes, row_headers, column_headers).call
{ group_count: total,
visible_output_count: output_row_count(prefix, row_keys, row_depths, total),
column_values:, row_headers:, column_headers:, pivot_totals: totals,
cells: records.map do |row|
{ values: axes.all.each_index.map { row["a#{ _1 }"] }, count: row['count'].to_i }
end, axis_labels: axis_labels(records, column_values) }
end
def details
query = population.scope.select(Arel.sql(
"(#{ population.latest_sql }) AS counts_latest"))
order = sort_sql { detail_expression(_1) }
query = query.order(Arel.sql((order + ['v.id DESC']).join(', ')))
.limit(limit).offset((page - 1) * limit)
rows = query.to_a
if ['posts', 'tags'].include?(@definition.resource)
point = population.history ? population.at :
population.timestamp(@params['at']) || population.at
values = CountsVersionRepr.many(rows, resource: @definition.resource,
language_code: locale.language_code,
at: point)
values.each_with_index { |value, index| value[:event_type] = rows[index].event_type }
else
users = User.where(id: rows.map(&:created_by_user_id)).pluck(:id, :name).to_h
values = rows.map do |row|
attributes = @definition.fields.keys.to_h do |key|
[key, row.public_send(@definition.column(key))]
end
attributes.merge('id' => row.id, 'resource_id' => row[@definition.keys.first],
'is_latest' => row.attributes['counts_latest'].to_i == 1,
'created_by_user' => row.created_by_user_id &&
{ id: row.created_by_user_id, name: users[row.created_by_user_id] })
end
end
{ versions: values }
end
def each_page
total = nil
loop do
result = call
total ||= mode == 'summary' ? result[:group_count] : result[:count]
yield result
break if page * limit >= total
@page += 1
end
end
private
def output_row_count(prefix, row_keys, depths, total)
# With no row axes the table has exactly one body row, including the zero-count case.
return 1 if row_keys.empty?
subtotals = depths.map do |depth|
keys = row_keys.first(depth).join(', ')
"(SELECT COUNT(*) FROM (SELECT #{ keys } FROM cells GROUP BY #{ keys }) subtotal_rows)"
end
expressions = [total.to_s, '1'] + subtotals
@output_row_count ||= @connection.select_value(<<~SQL).to_i
#{ prefix }
SELECT /*+ MAX_EXECUTION_TIME(10000) */ #{ expressions.join(' + ') } AS output_rows
SQL
end
def locale = @locale ||= Locale.find_by!(code: @params.fetch('locale', 'ja'))
def limited_columns(prefix)
keys = axes.columns.each_index.map { "a#{ axes.rows.length + _1 }" }
return [[]] if keys.empty?
configured = @sort.select { |key, _direction| keys.include?(key) }
directions = (configured + keys.map { [_1, 'asc'] }).uniq { _1.first }
order = directions.map { |key, direction|
"#{ key } IS NULL ASC, #{ key } #{ direction.upcase }"
}.join(', ')
custom = QueryState.records(@params, 'column_order')
if custom.any?
raise ArgumentError unless custom.map { _1['value'] }.uniq.length == custom.length
cases = custom.each_with_index.map do |record, index|
"WHEN #{ keys.first } <=> #{ @connection.quote(record['value']) } THEN #{ index }"
end.join(' ')
order = "CASE #{ cases } ELSE #{ custom.length } END, #{ order }"
end
values = @connection.select_rows(<<~SQL)
#{ prefix }
SELECT /*+ MAX_EXECUTION_TIME(10000) */ DISTINCT #{ keys.join(', ') } FROM cells
ORDER BY #{ order } LIMIT #{ MAX_COLUMNS + 1 }
SQL
raise TooManyColumns if values.length > MAX_COLUMNS
values
end
def measure_relation(values)
row_expressions = axes.expressions.first(axes.rows.length)
source = population.scope.except(:select, :order)
fields = axes.rows + axes.columns.first(values.length)
joins = axes.joins(fields.map { _1.fetch('field') })
source = source.joins(joins) unless joins.empty?
values.each_with_index do |value, index|
expression = axes.expressions[axes.rows.length + index]
source = source.where("(#{ expression }) <=> ?", value)
end
selected = row_expressions.each_with_index.map { |expression, index|
"#{ expression } AS a#{ index }"
} + ['COUNT(DISTINCT v.id) AS count']
source = source.group(*row_expressions.map { Arel.sql(_1) }) if row_expressions.any?
source.select(Arel.sql(selected.join(', '))).to_sql
end
def measure_values(key)
records = QueryState.records(@params, 'measures').select { _1['key'] == key }
raise ArgumentError if records.empty?
raise ArgumentError unless records.map { _1['axis'] }.sort == (0...records.length).to_a
value = records.sort_by { _1['axis'] }.map { _1['value'] }
unless (value.is_a?(Array) && value.length <= axes.columns.length &&
value.all? { _1.nil? || _1.is_a?(String) || _1.is_a?(Numeric) })
raise ArgumentError
end
value
end
def detail_expression(key)
return "(#{ population.latest_sql })" if key == 'is_latest'
aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ?
'parent_post_ids' : 'parent_tag_ids', 'deprecated' => 'deprecated_at' }
field = aliases.fetch(key, key)
expression = @definition.expression(field)
return expression unless @definition.fields[field] == :json
"CAST(#{ expression } AS CHAR) COLLATE utf8mb4_bin"
end
def sort_sql
@sort.flat_map do |key, direction|
expression = yield key
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
end
end
def axis_labels(records, column_values)
result = {}
axes.all.each_with_index do |axis, index|
values = records.map { _1["a#{ index }"] }
if index >= axes.rows.length
values += column_values.map { _1[index - axes.rows.length] }
end
values = values.compact.uniq
labels = case axis['field']
when 'user'
User.where(id: values).pluck(:id, :name).to_h.transform_keys(&:to_s)
when 'language_code', 'source_language_code'
Language.where(code: values).pluck(:code, :name).to_h
when 'tags'
tag_labels(values)
else {}
end
result["a#{ index }"] = labels
end
result
end
def tag_labels(values)
internal = values.grep(/\Ainternal:/).map { _1.split(':').last.to_i }
external = values.grep(/\Aexternal:/).map { _1.split(':').last.to_i }
point = population.timestamp(@params['at']) || population.at
labels = {}
sources = [[TagVersion, internal, 'internal'], [NicoTagVersion, external, 'external']]
sources.each do |model, ids, kind|
next if ids.empty?
table = model.table_name
model.where(tag_id: ids).where('created_at <= ?', point)
.where("NOT EXISTS (SELECT 1 FROM #{ table } newer " +
"WHERE newer.tag_id = #{ table }.tag_id AND newer.created_at <= ? " +
"AND newer.version_no > #{ table }.version_no)", point).each do |row|
names = row.respond_to?(:names) ? row.names : nil
name = names&.dig(locale.language_code, 'name') || row.name
labels["#{ kind }:#{ row.tag_id }"] = name
end
end
labels
end
end
-42
ファイルの表示
@@ -1,42 +0,0 @@
# Shared literal parsing and reference resolution for current and snapshot post search.
class PostTagSearch
def self.parse_references(raw, literals)
return nil unless raw.key?('tag_refs')
references = QueryState.records(raw, 'tag_refs')
valid = references.is_a?(Array) && references.all? do |reference|
next false unless reference.is_a?(Hash) && reference['name'].is_a?(String)
ids = [reference['tag_id'], reference['external_tag_id']].compact
((ids.any? || reference['missing'] == true) &&
ids.all? { _1.is_a?(Integer) && _1.positive? } &&
(!reference.key?('missing') || (reference['missing'] == true && ids.empty?)) &&
(!reference.key?('negative') || [true, false].include?(reference['negative'])))
end
raise ArgumentError unless valid
references.map { _1['name'] } == literals ? references : nil
end
def self.literals(locale, raw, references: nil)
raw.split.each_with_index.filter_map do |token, index|
next if token.downcase == 'type:or'
reference = references&.[](index)
name = token.sub(/\Anot:/i, '')
name = TagName.canonicalise(locale, name).first unless reference
negative = reference&.key?('negative') ? reference['negative'] :
token.downcase.start_with?('not:')
{ name:, negative:, reference: }
end
end
def self.references(locale, literal)
reference = literal[:reference]
return [reference['tag_id'], reference['external_tag_id']] if reference
[TagName.where(language_code: locale.language_code, name: literal[:name])
.distinct.pluck(:tag_id),
ExternalTag.where("CONCAT(platform, ':', name) = ?", literal[:name]).pluck(:id)]
end
end
-88
ファイルの表示
@@ -1,88 +0,0 @@
# Public query grammar shared with frontend/src/lib/queryState.ts.
class QueryState
SCHEMAS = {
'conditions' => ['field', 'operator', 'value', 'end', 'blank'],
'drill' => ['field', 'grain', 'value', 'value_type'],
'tag_refs' => ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'],
'column_order' => ['value', 'value_type'],
'measures' => ['key', 'axis', 'value', 'value_type'] }.freeze
def self.list(value)
return [] if value.nil? || value == ''
raise ArgumentError unless value.is_a?(String) && value.bytesize <= 32_768
values = value.split(/ /, -1)
raise ArgumentError if values.length > 200 || values.any?(&:empty?) || values.uniq != values
values
end
def self.valid_key?(key)
root, index, field, extra = key.split('.', -1)
!extra && SCHEMAS.key?(root) && index&.match?(/\A(?:0|[1-9][0-9]?)\z/) &&
SCHEMAS[root].include?(field)
end
def self.validate!(params, allowed)
raise ArgumentError if params.length > 1000
params.each do |key, value|
raise ArgumentError unless allowed.include?(key) || valid_key?(key)
raise ArgumentError unless value.is_a?(String) && value.bytesize <= 131_072
end
SCHEMAS.each_key { records(params, _1) }
end
def self.records(params, key)
records = {}
params.each do |name, value|
next unless name.start_with?("#{ key }.")
raise ArgumentError unless params.key?(key) && valid_key?(name)
_root, index, field = name.split('.')
record = records[index.to_i] ||= {}
if ['blank', 'negative', 'missing'].include?(field)
raise ArgumentError unless ['0', '1'].include?(value)
value = value == '1'
elsif ['tag_id', 'external_tag_id', 'axis'].include?(field)
raise ArgumentError unless value.match?(/\A[0-9]{1,18}\z/)
value = value.to_i
end
record[field] = value
end
raise ArgumentError if params.key?(key) && params[key] != ''
raise ArgumentError unless records.keys.sort == (0...records.length).to_a
records.sort.map do |_index, record|
type = record.delete('value_type')
if ['drill', 'measures', 'column_order'].include?(key)
raise ArgumentError unless ['null', 'string', 'number', 'boolean'].include?(type)
raise ArgumentError unless type == 'null' || record.key?('value')
value = record['value']
record['value'] = case type
when 'null' then nil
when 'number'
raise ArgumentError unless value&.match?(/\A-?[0-9]{1,18}\z/)
Integer(value, 10)
when 'boolean'
raise ArgumentError unless ['true', 'false'].include?(value)
value == 'true'
else value
end
end
record
end
end
def self.axes(value)
list(value).map do |token|
field, grain, extra = token.split(':', -1)
raise ArgumentError if extra || !field.match?(/\A[a-z_]+\z/) || grain == ''
grain ? { 'field' => field, 'grain' => grain } : { 'field' => field }
end
end
end
-7
ファイルの表示
@@ -1,7 +0,0 @@
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
-13
ファイルの表示
@@ -1,17 +1,4 @@
Rails.application.routes.draw do
get 'counts/definitions/:resource', to: 'counts_tables#definition'
get 'counts/query', to: 'counts_tables#index'
post 'counts/export/jobs', to: 'counts_exports#create_job'
get 'counts/export/jobs/:token', to: 'counts_exports#job_status'
delete 'counts/export/jobs/:token', to: 'counts_exports#cancel_job'
post 'counts/export/prepare', to: 'counts_exports#prepare'
get 'counts/export/files/:token', to: 'counts_exports#download'
get 'counts/export', to: 'counts_exports#index'
post 'counts/export', to: 'counts_exports#index'
get 'counts/state', to: 'counts_states#index'
get 'counts/posts', to: 'counts_posts#index'
get 'counts/tags', to: 'counts_tags#index'
resources :locales, only: [:index]
resources :sitemap_entries, only: [:index, :show], param: :resource
-11
ファイルの表示
@@ -1,11 +0,0 @@
namespace :counts do
desc 'Process the persistent Counts export queue (run under a process supervisor)'
task export_worker: :environment do
stopping = false
['TERM', 'INT'].each { |signal| Signal.trap(signal) { stopping = true } }
until stopping
worked = Rails.application.executor.wrap { CountsExportJobs.work_one }
sleep(1) unless worked
end
end
end
-96
ファイルの表示
@@ -1,96 +0,0 @@
require 'rails_helper'
RSpec.describe 'Counts API', type: :request do
let!(:actor) { create(:user, :member) }
let!(:post) { create(:post) }
let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) }
before do
snapshot = { tag_id: tag.id, version_no: 1, name: 'counts tag',
category: 'general', sections: [] }
[1, 2, 3].each do |number|
time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0)
PostVersion.create!(post:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '',
tags_json: number == 1 ? [snapshot] : [],
created_by_user: number == 3 ? nil : actor, created_at: time)
TagVersion.create!(tag:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'counts tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } },
created_by_user: number == 3 ? nil : actor, created_at: time)
end
end
['posts', 'tags'].each do |resource|
context resource do
let(:path) { "/counts/#{ resource }" }
let(:id) { resource == 'posts' ? post.id : tag.id }
let(:filters) { { resource_id: id, user_id: actor.id,
from: '2024-01-01T00:00Z', before: '2024-02-01T00:00Z' } }
it 'uses the same AND filters for SQL totals, groups and details' do
get path, params: filters.merge(axis: 'event_type')
expect(response).to have_http_status(:ok)
summary = response.parsed_body
expect(summary['count']).to eq(2)
expect(summary['groups'].sum { _1['count'] }).to eq(2)
expect(summary['groups'].map { _1['key'] }).to eq(['create', 'update'])
get path, params: filters.merge(mode: 'details')
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
end
it 'paginates details and groups independently from totals' do
get path, params: { resource_id: id, mode: 'details', limit: 1, page: 2 }
expect(response.parsed_body['count']).to eq(3)
expect(response.parsed_body['versions'].sole['version_no']).to eq(2)
get path, params: { resource_id: id, axis: 'month', limit: 1, page: 2 }
expect(response.parsed_body['group_count']).to eq(2)
expect(response.parsed_body['groups'].sole).to include('key' => '2024-02', 'count' => 1)
end
it 'groups null actors and supports their drill-down filter' do
get path, params: { resource_id: id, axis: 'user' }
expect(response.parsed_body['groups']).to include(
{ 'key' => nil, 'name' => nil, 'count' => 1 })
get path, params: { resource_id: id, mode: 'details', user_id: 'none' }
expect(response.parsed_body['count']).to eq(1)
end
it 'uses an inclusive start and exclusive end' do
get path, params: { resource_id: id,
from: '2024-01-01T00:00Z', before: '2024-01-02T00:00Z' }
expect(response.parsed_body['count']).to eq(1)
end
it 'rejects unsupported or malformed query values' do
[
{ axis: 'SQL' }, { limit: 101 }, { page: '1x' }, { user_id: '-1' },
{ event_type: 'invalid' }, { columns: 'password' }, { order: 'id' },
{ from: '2024-02-30T00:00Z' }, { from: '2024-01-01' },
{ user_id: ['1'] }, { mode: 'invalid' }].each do |invalid|
get path, params: invalid
expect(response).to have_http_status(:bad_request)
end
end
it 'retains the existing banned-user boundary' do
actor.update!(banned_at: Time.current)
sign_in_as(actor)
get path
expect(response).to have_http_status(:forbidden)
end
end
end
it 'matches removed tags from the previous snapshot, not current post_tags' do
get '/counts/posts', params: { tag: tag.id, mode: 'details' }
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
expect(response.parsed_body['versions'].first['tags'].sole['type']).to eq('removed')
end
end
-53
ファイルの表示
@@ -1,53 +0,0 @@
# Counts エクスポートワーカー
Counts の画面からのエクスポートは永続ファイルキューへ登録する。
Active Job の既定のプロセス内 async アダプターは使用しない。
追加 gem、外部キュー、DB マイグレーションは不要。
## 起動・配置
backend で `bundle exec rake counts:export_worker` を、systemd 等の監視対象として
別プロセスで起動する。Web プロセス内では起動しない。
ワーカー未起動の間は待機状態となる。1 プロセスは同時に1ジョブを処理し、
全体の実行枠は既存の2枠を共有する。ワーカーの起動・配備は実施していない。
Web とワーカーは同一リリース、Rails secret、アプリケーション環境、DB 接続先を
使用する。DB コネクションはワーカープロセスごとに必要。
`COUNTS_EXPORT_DIRECTORY` を同じ永続 POSIX 共有ディレクトリに設定する。
省略時は `backend/tmp/counts_exports`。
全プロセスで同一の所有者とアクセス権を使用する。
複数ホストでは、flock・同一ファイルシステム内の atomic rename・fsync が正しく
機能する共有ボリュームが必須。オブジェクトストレージや各コンテナ独立の tmp は不可。
ジョブ JSON と完成ファイルが格納されるため、このディレクトリを静的公開しない。
## API と進捗
- POST `/counts/export/jobs`:要求検証・実行時点の固定・署名付きジョブトークンの発行。
- GET `/counts/export/jobs/:token`:状態、実際の処理済みレコード数、判明している総数。
- DELETE `/counts/export/jobs/:token`:取消を永続記録。
- GET `/counts/export/files/:token`:生成完了後の署名付きファイルトークンによる取得。
ジョブ・ファイルのトークンは作成元のセッションに紐付く。
既存の BAN・CSRF 制御を使用する。フロントは約800ms間隔で状態を取得する。
明細の総数は同じ読み取りトランザクション内の SQL 件数を使用する。
集計結果の出力セル総数は生成終了まで確定させず、不確定進捗として扱う。
完成状態はファイルの生成完了であり、ブラウザの保存完了を意味しない。
## 取消・障害・制限
通常は約250ms間隔の生成チェックポイントで取消を検知する。
DB 問い合わせ・ファイル I/O がブロックしている間は即時に中断できない。
取消済みへの遷移は生成処理の終了と一時ファイル削除後に行う。
既に始まった HTTP 転送やブラウザ側の保存は取り消せない。
ワーカーが異常終了したジョブは、次のワーカーが実行ロックを取得した際に失敗状態にし、
そのジョブの残存生成ファイルを削除する。別の読み取り状態で自動再実行しない。
原子的に書き換える状態ファイルとプロセス間ロックにより、二重処理・取消との競合を防ぐ。
既存の同時実行2、1ファイル128MiB、保存領域512MiB、生成120秒、期限10分を維持する。
生成時間制限もチェックポイント方式であり、実行中 SQL の強制停止ではない。
期限切れのジョブ・ファイル・残存状態書込ファイルは登録時またはワーカーの巡回時に削除する。
ワーカーと Web の両方を停止した間は巡回削除も停止する。
これらの処理は静的確認のみで、実環境のキュー処理・取消・転送・共有ボリュームでの
排他動作は未確認。
+4 -32
ファイルの表示
@@ -1,20 +1,5 @@
# frontend/AGENTS.md
## Mandatory security and URL contracts
- URL クエリへの JSON 格納を禁止する。Base64・16進数等による迂回も禁止。
単純な配列は半角スペース区切り、構造化条件は番号付きの個別キーで表す。
未指定と明示的な空選択を区別し、自由入力を区切り文字で分割しない。
Frontend / Backend で同じ文法・上限・検証を使い、新しい URL 状態の追加前に確認する。
- 一度除票されたリソースの除票以前(除票版を含む)の履歴は admin 以外へ公開しない。
恢復しても解除しない。すべての後続除票を確定版番号と正しい複合キーで判定する。
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
## Scope
These rules apply to work under `frontend/`.
@@ -48,9 +33,9 @@ npm run lint
If either command cannot be run or fails, report the exact command and failure.
明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
fixture、mock、snapshot、テスト補助コード及び既存のテスト差分にも触れない。
ビルド・lint・静的確認を使い、未確認事項はそのまま報告する。
Do not create, modify, or run tests unless the user explicitly asks for test
work. When the user asks for tests, keep working and rerun them until they
pass or the remaining failure is clearly blocked.
## TypeScript
@@ -143,19 +128,6 @@ fixture、mock、snapshot、テスト補助コード及び既存のテスト差
## Tailwind and UI
- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。
新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。
不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
`_untranslated` 規約を使い、language metadata を維持する。
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
SSR / hydration の整合及び翻訳キーの非露出を検証する。
i18n の検証を理由にテスト関連作業を開始しない。
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
アクセシビリティ用ラベルも UI 文言として扱う。
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
@@ -172,7 +144,7 @@ fixture、mock、snapshot、テスト補助コード及び既存のテスト差
heavy borders, or nested panel decoration unless the surrounding screen
already uses them.
- Keep operational screens dense and direct; trim explanatory copy and use
short localised labels that fit the control.
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
生成ファイル
-15
ファイルの表示
@@ -11,7 +11,6 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@mdx-js/react": "^3.1.1",
@@ -575,20 +574,6 @@
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
-1
ファイルの表示
@@ -18,7 +18,6 @@
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@mdx-js/react": "^3.1.1",
+1 -89
ファイルの表示
@@ -62,15 +62,6 @@ const responseFor = url => {
return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
if (path === '/users/theme_slots') return []
if (path === '/counts/posts' || path === '/counts/tags') {
const resource = path.endsWith ('posts') ? 'posts' : 'tags'
return { count: 1, page: 1, limit: 20, group_count: 1,
groups: [{ key: url.searchParams.get ('axis') === 'user' ? 1 : '2024-01',
name: null, count: 1 }],
versions: resource === 'posts'
? [{ post_id: 1, version_no: 1, title: { current: `SSR count ${locale}` } }]
: [{ tag_id: 1, version_no: 1, name: { current: `SSR count ${locale}` } }] }
}
if (path === '/posts') return { posts: [post (locale)], count: 1 }
if (path === '/gekanator/posts') return { posts: [post (locale)] }
if (path === '/gekanator/questions') return { questions: [] }
@@ -309,25 +300,6 @@ after (async () => {
if (errors.length) throw new AggregateError (errors, 'SSR teardown failed')
})
test ('counts SSR renders history data and retains URL query state', async () => {
for (const locale of ['ja', 'ain-Kana']) {
for (const resource of ['posts', 'tags']) {
const column = resource === 'posts' ? 'title' : 'name'
const query = `mode=details&columns=${column}&user_id=4`
const result = await renderPage (`/${locale}/counts/${resource}?${query}`, origin, apiURL)
assert.equal (result.status, 200)
assert.match (result.html, new RegExp (`SSR count ${locale}`))
assert.match (result.html, new RegExp (`/${locale}/${resource}/1`))
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
assert.match (result.html, /aria-expanded="false"/)
if (locale === 'ain-Kana')
assert.match (result.html, /lang="ja"[^>]*>集計実行/)
assert.ok (requests.some (request => request.url.includes (`/counts/${resource}`)
&& request.url.includes ('user_id=4') && request.url.includes (`locale=${locale}`)))
}
}
})
test ('every canonical screen route returns page HTML without JavaScript', async t => {
const cookie = await authenticatedCookie ()
for (const route of routes) {
@@ -610,8 +582,7 @@ test ('hydrate every route with stored browser preferences', {
}` }, sessionId)
const evaluate = async expression => (await send ('Runtime.evaluate', {
expression, returnByValue: true }, sessionId)).result.value
for (const route of routes.filter (entry => !entry.handle?.redirectTo
&& (process.env.SSR_COUNTS_ONLY !== '1' || entry.path.startsWith ('counts')))) {
for (const route of routes.filter (entry => !entry.handle?.redirectTo)) {
for (const locale of ['ja', 'ain-Kana']) {
await t.test (`hydrate ${locale} ${route.path}`, async () => {
const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
@@ -637,65 +608,6 @@ test ('hydrate every route with stored browser preferences', {
})
}
}
for (const width of [375, 1240]) {
await send ('Emulation.setDeviceMetricsOverride', {
width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId)
for (const resource of ['posts', 'tags']) {
await t.test (`counts layout ${width} ${resource}`, async () => {
await send ('Page.navigate', {
url: `${httpOrigin}/ja/counts/${resource}` }, sessionId)
let ready = false
for (let attempt = 0; attempt < 100; ++attempt) {
await new Promise (resolve => setTimeout (resolve, 75))
try {
ready = await evaluate (`document.querySelector('tbody tr')!=null`)
} catch {}
if (ready) break
}
assert.ok (ready, 'history query results must render')
const layout = await evaluate (`(()=>{
const nav=document.getElementById('counts-targets');
const main=document.querySelector('main').getBoundingClientRect();
return {visible:nav.getClientRects().length>0,
right:nav.getBoundingClientRect().right,left:main.left,width:main.width};
})()`)
assert.ok (width >= 768 ? layout.visible && layout.right <= layout.left
: !layout.visible && layout.width >= width - 16,
'the sidebar sits beside the table on desktop and collapses on mobile')
const toggle = `Array.from(document.querySelectorAll('button'))
.find(button=>button.textContent.includes('集計条件'))`
assert.equal (await evaluate (`(${toggle}).getAttribute('aria-expanded')`), 'false')
await evaluate (`(${toggle}).click()`)
const positions = await evaluate (`(()=>{
const output=document.querySelector('[data-counts-output]').getBoundingClientRect();
const filters=document.querySelector('[data-counts-filters]').getBoundingClientRect();
return {left:output.left,right:filters.left,top:output.top,bottom:filters.top};
})()`)
assert.ok (width >= 768 ? positions.left < positions.right
: positions.top < positions.bottom, 'output precedes filters in the layout')
assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'))
if (resource === 'posts' && process.env.SSR_COUNTS_ONLY === '1') {
const { writeFile } = await import ('node:fs/promises')
const shot = await send ('Page.captureScreenshot', { format: 'png' }, sessionId)
await writeFile (`/tmp/counts-${width}-conditions.png`,
Buffer.from (shot.data, 'base64'))
}
await evaluate (`(${toggle}).click()`)
for (const dark of [false, true]) {
await evaluate (`document.documentElement.classList.toggle('dark',${dark})`)
assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'),
'counts controls must fit the viewport in both themes')
if (resource === 'posts' && process.env.SSR_COUNTS_ONLY === '1') {
const { writeFile } = await import ('node:fs/promises')
const shot = await send ('Page.captureScreenshot', { format: 'png' }, sessionId)
await writeFile (`/tmp/counts-${width}-${dark ? 'dark' : 'light'}.png`,
Buffer.from (shot.data, 'base64'))
}
}
})
}
}
if (process.env.SSR_COUNTS_ONLY === '1') return
probeActive = true
for (const width of [375, 1240]) {
await send ('Emulation.setDeviceMetricsOverride', {
+5 -34
ファイルの表示
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { createElement, Suspense, lazy, useCallback, useEffect, useRef,
import { createElement, Suspense, lazy, useEffect,
useLayoutEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createBrowserRouter,
@@ -77,8 +77,6 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
const MaterialSyncSuppressionsPage = lazyPage (
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
const CountsPage = lazyPage (() => import ('@/pages/CountsPage'))
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
const MorePage = lazyPage (() => import ('@/pages/MorePage'))
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage'))
@@ -196,20 +194,6 @@ export const pageRoutes = (
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'counts', element: <CountsPage/>,
handle: { preload: CountsPage.preload } },
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/nico_tags', element: <CountsResourcePage resource="nico_tags"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tag_descriptions', element: <CountsResourcePage resource="tag_descriptions"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tag_groups', element: <CountsResourcePage resource="tag_groups"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/materials', element: <CountsResourcePage resource="materials"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'more', element: <MorePage/>,
handle: { preload: MorePage.preload, sitemap: ['static'] } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
@@ -261,27 +245,14 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const locale = useLocale ()
const route = resolvePageRoute (location.pathname)
const robots = route?.route.handle?.robots
const pagePath = unlocalisedPath (location.pathname)
const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/')
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
const resolve = route?.route.handle?.switchLocale
return (resolve != null && route != null)
? resolve ({ location, params: route.params, locale, targetLocale })
: switchLocalePath (location, targetLocale)
}
const [user, setUserState] = useState<User | null> (
const [user, setUser] = useState<User | null> (
() => client.getQueryData<User> (sessionKeys.current) ?? null)
const authority = useRef (user)
const setUser = useCallback<Dispatch<SetStateAction<User | null>>> (action => {
const next = typeof action === 'function' ? action (authority.current) : action
if (next?.id !== authority.current?.id || next?.role !== authority.current?.role)
{
client.cancelQueries ()
client.removeQueries ({ predicate: query => query.queryKey[0] !== 'session' })
}
authority.current = next
setUserState (next)
}, [client])
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
@@ -330,7 +301,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
}
}) ()
return () => { cancelled = true }
}, [setUser])
}, [])
useEffect (() => {
if (!(user))
@@ -379,7 +350,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
}>
<LayoutGroup>
<motion.div
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
@@ -422,7 +393,7 @@ const LocaleRoute: FC = () => {
void i18n.changeLanguage (activeLocale)
}, [activeLocale, i18n])
const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null)
const redirect = legacyLocalePath (location)
if (!(isLocaleCode (locale)) && redirect == null)
return <NotFound/>
if (isError && data == null)
-22
ファイルの表示
@@ -1,22 +0,0 @@
import { motion } from 'framer-motion'
import { useClientMotion } from '@/lib/useClientMotion'
import { cn } from '@/lib/utils'
import type { ReactNode } from 'react'
const AnimatedCollapse = ({ open, children, id, className }: {
open: boolean; children: ReactNode; id?: string; className?: string },
) => {
const motionSettings = useClientMotion ()
return <motion.div id={id} initial={false} aria-hidden={!open} inert={!open}
animate={{ height: open ? 'auto' : 0, opacity: open ? 1 : 0 }}
transition={motionSettings.transition ('collapse')}
className={cn ('min-h-0 overflow-hidden', className)}>
{children}
</motion.div>
}
export default AnimatedCollapse
+8 -12
ファイルの表示
@@ -8,13 +8,13 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
const pad = (n: number): string => n.toString ().padStart (2, '0')
const toDateTimeLocalValue = (value: Date, seconds = false) => {
const toDateTimeLocalValue = (value: Date) => {
const y = value.getFullYear ()
const m = pad (value.getMonth () + 1)
const day = pad (value.getDate ())
const h = pad (value.getHours ())
const min = pad (value.getMinutes ())
return `${ y }-${ m }-${ day }T${ h }:${ min }${seconds ? `:${pad (value.getSeconds ())}` : ''}`
return `${ y }-${ m }-${ day }T${ h }:${ min }`
}
@@ -37,17 +37,15 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
onChange?: (isoUTC: string | null) => void
className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void
invalid?: boolean
precision?: 'minute' | 'second' }
invalid?: boolean }
const DateTimeField: FC<Props> = props => {
const { value, onChange, className, onBlur, invalid, precision = 'minute', ...rest } = props
const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid, ...rest }) => {
const [local, setLocal] = useState ('')
useEffect (() => {
setLocal (value ? toDateTimeLocalValue (new Date (value), precision === 'second') : '')
}, [value, precision])
setLocal (value ? toDateTimeLocalValue (new Date (value)) : '')
}, [value])
return (
<input
@@ -63,15 +61,13 @@ const DateTimeField: FC<Props> = props => {
'focus:ring-2 focus:ring-blue-200']),
className)}
type="datetime-local"
step={precision === 'second' ? 1 : 60}
step={60}
value={local}
aria-invalid={invalid}
onChange={ev => {
const v = ev.target.value
setLocal (v)
const iso = v ? (precision === 'second'
? new Date (v).toISOString () : toMinutePrecisionIsoUtc (v)) : null
onChange?.(iso)
onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
}}
onBlur={onBlur}/>)
}
-35
ファイルの表示
@@ -1,35 +0,0 @@
import { useId, useRef } from 'react'
import { CircleHelp } from 'lucide-react'
import type { ReactNode } from 'react'
const HelpPopover = ({ label, children }: {
label: { text: string; language: string }; children: ReactNode },
) => {
const id = useId ()
const trigger = useRef<HTMLButtonElement> (null)
const panel = useRef<HTMLDivElement> (null)
return <>
<button ref={trigger} type="button" popoverTarget={id} aria-label={label.text}
lang={label.language} className="inline-flex align-middle"
onClick={() => {
const rect = trigger.current?.getBoundingClientRect ()
if (panel.current != null && rect != null)
{
panel.current.style.top = `${rect.bottom + 4}px`
panel.current.style.left = `${Math.min (rect.left, window.innerWidth - 288)}px`
}
}}><CircleHelp size={14}/></button>
<div ref={panel} id={id} popover="auto" tabIndex={-1}
role="dialog" aria-label={label.text} lang={label.language}
onToggle={event => {
if (event.newState === 'open') panel.current?.focus ()
}} className="fixed m-0 w-72 max-w-[90vw] rounded border bg-white p-3
text-sm text-slate-900 shadow-lg dark:border-slate-600 dark:bg-slate-900
dark:text-slate-100">{children}</div>
</>
}
export default HelpPopover
-79
ファイルの表示
@@ -1,79 +0,0 @@
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
-58
ファイルの表示
@@ -1,58 +0,0 @@
import { defaultAnimateLayoutChanges, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useClientMotion } from '@/lib/useClientMotion'
import { cn } from '@/lib/utils'
import type { CSSProperties, ReactNode } from 'react'
type Name = { text: string; language: string }
const SortableItem = ({ id, name, fixed, children, moveGroup, handleId, renderShell }: {
id: string; name: Name; fixed?: boolean; children: ReactNode
moveGroup: (direction: number) => void; handleId: string
renderShell?: (shell: { ref: (node: HTMLElement | null) => void
style: CSSProperties; handle: ReactNode; dragging: boolean }) => ReactNode },
) => {
const motion = useClientMotion ()
const item = useSortable ({ id, disabled: fixed,
transition: { duration: motion.duration ('reorder'), easing: motion.easing },
animateLayoutChanges: options => !motion.reduced
&& defaultAnimateLayoutChanges ({ ...options, wasDragging: true }) })
const handle = fixed ? <span className="w-6 shrink-0"/> : <button type="button"
id={handleId} ref={item.setActivatorNodeRef} {...item.attributes} {...item.listeners}
aria-label={name.text} lang={name.language}
aria-keyshortcuts={renderShell == null
? 'Space ArrowUp ArrowDown Enter Escape Alt+ArrowLeft Alt+ArrowRight'
: 'Space ArrowLeft ArrowRight Enter Escape'}
className="mt-1 w-6 shrink-0 cursor-grab touch-none rounded
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
active:cursor-grabbing dark:focus-visible:ring-blue-400"
onClick={event => event.stopPropagation ()}
onKeyDown={event => {
if (event.altKey && !item.isDragging
&& ['ArrowLeft', 'ArrowRight'].includes (event.key))
{
event.preventDefault ()
event.stopPropagation ()
moveGroup (event.key === 'ArrowLeft' ? -1 : 1)
}
else item.listeners?.onKeyDown?.(event)
}}><span aria-hidden>⠿</span></button>
const style = { transform: CSS.Transform.toString (item.transform), transition: item.transition }
if (renderShell != null)
return renderShell ({ ref: item.setNodeRef, style, handle, dragging: item.isDragging })
return <div ref={item.setNodeRef} style={style}
className={cn ('relative max-w-full rounded border border-slate-300 bg-white',
'text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100',
item.isDragging && 'opacity-30 ring-2 ring-blue-500 dark:ring-blue-400')}>
<div className="flex min-w-0 items-start gap-1 px-1">
{handle}
<div className="min-w-0 flex-1 py-1">{children}</div>
</div>
</div>
}
export default SortableItem
-136
ファイルの表示
@@ -1,136 +0,0 @@
import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor,
closestCenter, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
import { SortableContext, arrayMove, rectSortingStrategy,
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import SortableItem from '@/components/common/SortableItem'
import { useClientMotion } from '@/lib/useClientMotion'
import { cn } from '@/lib/utils'
import type { ReactNode } from 'react'
type Groups = Record<string, string[]>
type Name = { text: string; language: string }
const Group = ({ id, children }: { id: string; children: ReactNode }) => {
const drop = useDroppable ({ id: `group:${id}` })
return <div ref={drop.setNodeRef} className={cn (
'flex min-h-10 flex-wrap items-start gap-1 rounded p-1',
drop.isOver && 'ring-1 ring-blue-500 dark:ring-blue-400')}>{children}</div>
}
const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed = [] }: {
groups: Groups; name: (id: string) => Name
renderItem: (id: string, group: string) => ReactNode
renderGroup: (id: string, content: ReactNode) => ReactNode
onChange: (groups: Groups) => void; fixed?: string[] },
) => {
const [preview, setPreview] = useState<Groups | null> (null)
const previewRef = useRef<Groups | null> (null)
const lastOver = useRef<string | null> (null)
const scope = useId ()
const [active, setActive] = useState<string | null> (null)
const motion = useClientMotion ()
const current = preview ?? groups
const sensors = useSensors (
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }),
useSensor (KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }))
const owner = (id: string, value: Groups) => Object.keys (value)
.find (group => value[group].includes (id))
const relocate = (value: Groups, id: string, over: string): Groups => {
const source = owner (id, value)
const target = over.startsWith ('group:') ? over.slice (6) : owner (over, value)
if (source == null || target == null || fixed.includes (id) || fixed.includes (over))
return value
if (source === target)
{
const from = value[source].indexOf (id)
const to = over.startsWith ('group:')
? value[target].length - 1 : value[target].indexOf (over)
if (from === to || to < 0) return value
return { ...value, [source]: arrayMove (value[source], from, to) }
}
const destination = value[target].filter (item => item !== id)
const position = destination.indexOf (over)
destination.splice (position < 0 ? destination.length : position, 0, id)
return { ...value, [source]: value[source].filter (item => item !== id),
[target]: destination }
}
const moveGroup = (id: string, direction: number) => {
const keys = Object.keys (groups)
const group = owner (id, groups)
const target = keys[keys.indexOf (group ?? '') + direction]
if (target != null)
{
onChange (relocate (groups, id, `group:${target}`))
requestAnimationFrame (() => document.getElementById (`${scope}-${id}`)?.focus ())
}
}
const reset = () => {
previewRef.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 : (
event.over.id.toString ().startsWith ('group:') ? undefined
: name (String (event.over.id)).text),
onDragMove: () => undefined, onDragEnd: () => undefined,
onDragCancel: () => undefined },
screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }}
onDragStart={event => {
previewRef.current = groups
lastOver.current = null
setPreview (groups)
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 (lastOver.current === target) return
const next = relocate (previewRef.current ?? groups, String (event.active.id), target)
previewRef.current = next
lastOver.current = target
setPreview (next)
}} onDragCancel={reset} onDragEnd={event => {
if (event.over != null)
{
const id = String (event.active.id)
const target = String (event.over.id)
const latest = previewRef.current ?? groups
const next = (target === id || target === lastOver.current)
? latest : relocate (latest, id, target)
onChange (next)
}
reset ()
}}>
{Object.entries (current).map (([group, ids]) => <div key={group}>
{renderGroup (group, <SortableContext items={ids} strategy={rectSortingStrategy}>
<Group id={group}>{ids.map (id => <SortableItem key={id} id={id}
name={name (id)} fixed={fixed.includes (id)} handleId={`${scope}-${id}`}
moveGroup={direction => moveGroup (id, direction)}>
{renderItem (id, group)}
</SortableItem>)}</Group>
</SortableContext>)}
</div>)}
{typeof document !== 'undefined' && createPortal (
<DragOverlay dropAnimation={motion.reduced ? null : {
duration: motion.duration ('drop'), easing: motion.easing }}>
{active == null ? null : <div lang={name (active).language}
className="rounded border border-blue-500 bg-white px-3 py-2 text-sm
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 SortableList
+4 -7
ファイルの表示
@@ -12,7 +12,6 @@ import type { Tag } from '@/types'
type Props = {
includeNico?: boolean
includeUnused?: boolean
describedBy?: string
invalid?: boolean
value: string
@@ -21,7 +20,6 @@ type Props = {
const TagInput: FC<Props> = ({
includeNico = false,
includeUnused = false,
describedBy,
invalid,
value,
@@ -59,11 +57,11 @@ const TagInput: FC<Props> = ({
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal,
params: { q: request.query.replace (/^not:/i, ''), nico: includeNico ? '1' : '0' } })
params: { q: request.query, nico: includeNico ? '1' : '0' } })
if (active)
{
setResult ({ locale, includeNico, request,
tags: includeUnused ? tags : tags.filter (tag => tag.postCount > 0) })
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
@@ -76,7 +74,7 @@ const TagInput: FC<Props> = ({
active = false
controller.abort ()
}
}, [request, locale, includeNico, includeUnused])
}, [request, locale, includeNico])
useEffect (() => {
setActiveIndex (-1)
@@ -87,8 +85,7 @@ const TagInput: FC<Props> = ({
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ')
const negative = /^not:/i.test (parts[parts.length - 1])
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
parts[parts.length - 1] = tag.name
setValue (parts.join (' ') + ' ')
setRequest ({ query: '' })
setActiveIndex (-1)
-141
ファイルの表示
@@ -1,141 +0,0 @@
import { useEffect, useState } from 'react'
import DateTimeField from '@/components/common/DateTimeField'
import { Button } from '@/components/ui/button'
import { countsEventTypes, countsFieldLabel, countsFilterFields,
useCountsDefinition }
from '@/lib/countsDefinitions'
import useDialogue from '@/lib/dialogues/useDialogue'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, encodeCountState } from '@/lib/queryState'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
const ConditionEditor = (
{ definition, initial, controls, save }: {
definition: CountsDefinition; initial?: CountsCondition
controls: DialogueFormControls
save: (condition: CountsCondition) => void },
) => {
const { label, message } = useUITranslation ('counts')
const fields = definition.filterFields ?? definition.fields
const fieldName = (key: string) => (fields.find (item => item.key === key)?.labels
?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ')
const [condition, setCondition] = useState<CountsCondition> (() => {
const next = { ...initial, field: initial?.field ?? 'resource_id',
operator: initial?.operator ?? 'eq' }
if (next.operator !== 'range') delete next.end
return next
})
const { field, operator } = condition
const operators = fields.find (item => item.key === field)?.operators ?? []
useEffect (() => {
controls.setActions ([{ label: label (initial == null ? 'add' : 'shared:actions.edit'),
onSelect: () => save (condition) }])
}, [controls, condition, initial, label, save])
return <div className="space-y-2">
<select value={field} aria-label={message ('field').text} className={inputClass ()}
onChange={event => {
const field = event.target.value
const operator = fields.find (item => item.key === field)?.operators[0]
if (operator != null) setCondition ({ field, operator })
}}>
{fields.filter (field => field.operators.length > 0).map (field => (
<option key={field.key} value={field.key}>{fieldName (field.key)}</option>))}
</select>
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
onChange={event => setCondition (previous => {
const operator = event.target.value
const next = { ...previous, operator }
if (operator !== 'range') delete next.end
if ((operator === 'like') !== (previous.operator === 'like')) delete next.value
return next
})}>
{operators.map (key => <option key={key} value={key}>
{message (`operators.${key}`).text}</option>)}
</select>
</div>
}
const CountsAdditionalConditions = ({ params, update }: {
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition ()
const dialogue = useDialogue ()
const conditions = readCountState<CountsCondition[]> (params, 'conditions', [])
const metadata = definition.filterFields ?? definition.fields
const fieldName = (key: string) => (metadata.find (item => item.key === key)?.labels
?? [countsFieldLabel (key)]).map (key => message (key).text).join (' › ')
const write = (next: CountsCondition[]) => update ('conditions', encodeCountState ('conditions', next))
const edit = (index?: number) => {
const initial = index == null ? undefined : conditions[index]
const save = (condition: CountsCondition) => {
write (index == null ? [...conditions, condition]
: conditions.map ((item, position) => position === index ? condition : item))
}
dialogue.form ({ title: label ('additionalConditions'), footerLayout: 'inline',
body: controls => <ConditionEditor definition={definition}
initial={initial} controls={controls} save={save}/> })
}
const patch = (index: number, change: Partial<CountsCondition>) => write (
conditions.map ((item, position) => position === index ? { ...item, ...change } : item))
const valueInput = (condition: CountsCondition, index: number, key: 'value' | 'end') => {
const type = countsFilterFields (definition)[condition.field]
const aria = message (key === 'end' ? 'through' : 'value')
if (type === 'boolean' || condition.field === 'event_type')
{
const options: string[] = type === 'boolean' ? ['true', 'false'] : [...countsEventTypes]
const namespace = type === 'boolean' ? 'truthValues' : 'events'
return <select key={key} value={condition[key] ?? ''} disabled={condition.blank}
className={inputClass (false, 'w-36 py-1')} aria-label={aria.text}
onChange={event => patch (index, { [key]: event.target.value })}>
<option value=""/>
{options.map (value => <option key={value} value={value}
lang={message (`${namespace}.${value}`).language}>
{message (`${namespace}.${value}`).text}</option>)}
</select>
}
if (type === 'datetime')
return <DateTimeField precision="second" key={key}
value={condition[key] ?? ''} disabled={condition.blank}
aria-label={aria.text} lang={aria.language}
onChange={value => patch (index, { [key]: value ?? '' })}/>
return <input key={key} value={condition[key] ?? ''} disabled={condition.blank}
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
className={inputClass (false, 'w-36 py-1')}
aria-label={aria.text} lang={aria.language}
onChange={event => patch (index, { [key]: event.target.value })}/>
}
return <fieldset className="space-y-1">
<legend className="flex items-center gap-2">{label ('additionalConditions')}
<Button type="button" variant="outline" size="sm" onClick={() => edit ()}>
{label ('add')}</Button></legend>
{conditions.map ((condition, index) => {
return <div key={index} className="flex flex-wrap items-center gap-1">
<button type="button" onClick={() => edit (index)}>
{fieldName (condition.field)}
{' · '}{label (`operators.${condition.operator}`)}
</button>
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const)
.map (key => valueInput (condition, index, key))}
<label className="inline-flex items-center gap-1">
<input type="checkbox" checked={condition.blank ?? false}
onChange={event => patch (index, { blank: event.target.checked })}/>
{label ('blank')}</label>
<button type="button" aria-label={message ('remove').text}
onClick={() => write (conditions.filter (
(_, position) => index !== position))}>×</button>
</div>
})}
</fieldset>
}
export default CountsAdditionalConditions
-95
ファイルの表示
@@ -1,95 +0,0 @@
import LocalisedText from '@/components/LocalisedText'
import SortableList from '@/components/common/SortableList'
import { countsAxisFields, countsAxisLabel, countsFilterFields,
useCountsDefinition }
from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
import { cn, inputClass } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, encodeCountState } from '@/lib/queryState'
import type { CountsAxis } from '@/lib/countsDefinitions'
const CountsAxes = ({ params, update }: {
params: URLSearchParams; update: (key: string, value: string) => void },
) => {
const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition ()
const locale = useLocale ()
const motion = useClientMotion ()
const available = countsAxisFields (definition, params.get ('definition') === 'history')
const rows = readCountState<CountsAxis[]> (params, 'rows', [])
const cols = readCountState<CountsAxis[]> (params, 'cols', [])
const axes = [...rows, ...cols]
const groups = {
available: available.map (field => field.key).filter (id => !axes.some (
axis => axis.field === id)),
rows: rows.map (axis => axis.field), cols: cols.map (axis => axis.field) }
const name = (id: string) => {
const axis = axes.find (item => item.field === id)
return axis == null ? message (available.find (field => field.key === id)?.label ?? 'axis')
: countsAxisLabel (axis, definition, locale)
}
const commit = (next: Record<string, string[]>) => {
for (const group of ['rows', 'cols'] as const)
update (group, encodeCountState (group, next[group].map (field => axes.find (
axis => axis.field === field) ?? { field,
...(countsFilterFields (definition)[field] === 'datetime' ? { grain: 'month' } : { }) })))
}
const place = (id: string, target?: 'rows' | 'cols') => {
const next = Object.fromEntries (Object.entries (groups).map (([key, ids]) => [
key, ids.filter (value => value !== id)]))
if (target != null) next[target].push (id)
else next.available.push (id)
commit (next)
}
return <SortableList groups={groups} name={name} onChange={commit}
renderGroup={(id, content) => <fieldset className="mb-2 min-w-0">
<legend>{label (id === 'available' ? 'axisItems' : id)}</legend>
<div className="rounded border dark:border-slate-700">{content}</div>
</fieldset>} renderItem={(id, group) => {
const axis = axes.find (axis => axis.field === id)
return <div className={cn ('flex items-center gap-2 text-sm',
group !== 'available' && 'flex-wrap')}>
<span className="min-w-0 flex-1 break-words">
<LocalisedText language={name (id).language}>{name (id).text}</LocalisedText>
</span>
{group === 'available' && <span className="ml-auto inline-flex shrink-0 gap-1">
{(['rows', 'cols'] as const).map (target => <button key={target} type="button"
className="h-7 w-7 rounded-full border border-slate-300 bg-slate-100
text-xs text-slate-700 hover:bg-blue-100 hover:text-blue-900
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100
dark:hover:bg-blue-900 dark:hover:text-blue-100 dark:focus-visible:ring-blue-400"
style={{ transition: `background-color ${motion.duration ('feedback')}ms ${
motion.easing}, color ${motion.duration ('feedback')}ms ${motion.easing}` }}
aria-label={message ('axisPlace', {
field: name (id).text, target: message (target).text }).text}
lang={message ('axisPlace').language} onClick={() => place (id, target)}>
{label (target)}</button>)}
</span>}
{group !== 'available' && <>
{countsFilterFields (definition)[id] === 'datetime' && <select
className={inputClass (false, 'w-auto py-0')}
value={axis?.grain ?? 'month'} aria-label={message ('grain').text}
onChange={event => update (group, encodeCountState (group,
(group === 'rows' ? rows : cols).map (item => item.field === id
? { ...item, grain: event.target.value } : item)))}>
{definition.grains.map (grain => <option key={grain} value={grain}
lang={message (`grains.${grain}`).language}>
{message (`grains.${grain}`).text}</option>)}
</select>}
<button type="button" aria-label={message ('remove').text}
lang={message ('remove').language} className="rounded px-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
dark:focus-visible:ring-blue-400" onClick={() => place (id)}>×</button>
</>}
</div>
}}/>
}
export default CountsAxes
-88
ファイルの表示
@@ -1,88 +0,0 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useUITranslation } from '@/lib/useUITranslation'
export type CountsMenuAction = 'copy' | 'copyHeaders' | 'row' | 'column' | 'all'
const CountsCellMenu = (
{ x, y, act, close }: {
x: number
y: number
act: (action: CountsMenuAction) => void
close: () => void },
) => {
const root = useRef<HTMLDivElement> (null)
const { label, message } = useUITranslation ('counts')
useLayoutEffect (() => {
const menu = root.current
if (menu == null) return
const rect = menu.getBoundingClientRect ()
menu.style.left = `${Math.max (4, Math.min (x, window.innerWidth - rect.width - 4))}px`
menu.style.top = `${Math.max (4, Math.min (y, window.innerHeight - rect.height - 4))}px`
menu.querySelector ('button')?.focus ({ preventScroll: true })
}, [x, y])
useEffect (() => {
const outside = (event: Event) => {
if (!root.current?.contains (event.target as Node)) close ()
}
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape')
{
event.preventDefault ()
event.stopPropagation ()
close ()
}
}
document.addEventListener ('pointerdown', outside, true)
document.addEventListener ('keydown', escape, true)
window.addEventListener ('resize', close)
document.addEventListener ('scroll', outside, true)
return () => {
document.removeEventListener ('pointerdown', outside, true)
document.removeEventListener ('keydown', escape, true)
window.removeEventListener ('resize', close)
document.removeEventListener ('scroll', outside, true)
}
}, [close])
const actions: CountsMenuAction[] = ['copy', 'copyHeaders', 'row', 'column', 'all']
return createPortal (
<div ref={root} role="menu" aria-label={message ('selection.table').text}
className="fixed z-50 min-w-40 rounded border border-slate-400 bg-white
py-1 text-sm text-slate-900 shadow-lg dark:border-slate-600
dark:bg-slate-800 dark:text-slate-100"
style={{ left: x, top: y }} onContextMenu={event => event.preventDefault ()}
onPointerDown={event => event.stopPropagation ()}
onKeyDown={event => {
event.stopPropagation ()
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase () === 'c')
{
event.preventDefault ()
act ('copy')
return
}
const buttons = Array.from (root.current?.querySelectorAll ('button') ?? [])
const current = buttons.indexOf (document.activeElement as HTMLButtonElement)
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes (event.key))
{
event.preventDefault ()
const next = event.key === 'Home' ? 0 : (event.key === 'End' ? buttons.length - 1
: (current + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length)
% buttons.length)
buttons[next]?.focus ()
}
if (event.key === 'Tab') close ()
}}>
{actions.map (action => (
<button key={action} type="button" role="menuitem"
className="block w-full px-3 py-1 text-left hover:bg-slate-200
hover:text-slate-900 focus:bg-slate-200 focus:text-slate-900
dark:hover:bg-slate-700 dark:hover:text-slate-100
dark:focus:bg-slate-700 dark:focus:text-slate-100"
onClick={() => act (action)}>{label (`selection.${action}`)}</button>))}
</div>, document.body)
}
export default CountsCellMenu
-223
ファイルの表示
@@ -1,223 +0,0 @@
import { useEffect, useId, useRef, useState } from 'react'
import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions'
import CountsAxes from '@/components/counts/CountsAxes'
import CountsFieldTree from '@/components/counts/CountsFieldTree'
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import HelpPopover from '@/components/common/HelpPopover'
import TagInput from '@/components/common/TagInput'
import { Button } from '@/components/ui/button'
import { countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
import { countsNestedIsScalar } from '@/lib/countsDisplay'
import { useCountsDefinition } from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
import { inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { readCountState, setCountState } from '@/lib/queryState'
import type { ReactNode } from 'react'
import type { CountsResource } from '@/lib/counts'
const CountsControls = (
{ resource, applied, open, setOpen, execute, displayJSON, displayRowNumbers,
displayThumbnail, reorder, actions, pending = false }: {
resource: CountsResource; applied: URLSearchParams; open: boolean
pending?: boolean
setOpen: (open: boolean) => void
execute: (params: URLSearchParams) => void
displayJSON: (enabled: boolean) => void
displayRowNumbers: (enabled: boolean) => void
displayThumbnail: (enabled: boolean) => void
reorder: (value: string) => void; actions?: ReactNode },
) => {
const { label, message } = useUITranslation ('counts')
const definition = useCountsDefinition ()
const id = useId ()
const motion = useClientMotion ()
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
const thumbnail = selectedCountColumns (resource, applied, definition).includes ('thumbnail')
const previousApplied = useRef ({ resource, params: new URLSearchParams (applied) })
const appliedState = applied.toString ()
const mode = draft.get ('mode') ?? 'summary'
const columns = selectedCountColumns (resource, draft, definition)
const details = mode === 'details'
const hasNested = details && fieldsForCounts (resource, definition).some (field =>
columns.includes (field.key) && field.children != null
&& !countsNestedIsScalar (field.children, field.key, draft)
&& field.children.some (child => selectedCountChildren (draft)
.has (`${field.key}.${child.key}`)))
useEffect (() => {
const nextApplied = new URLSearchParams (appliedState)
const previous = previousApplied.current
previousApplied.current = { resource, params: nextApplied }
setDraft (draft => {
if (previous.resource !== resource
|| previous.params.get ('definition') !== nextApplied.get ('definition'))
return nextApplied
const keys = new Set ([...previous.params.keys (), ...nextApplied.keys ()])
const changed = [...keys].filter (key =>
previous.params.get (key) !== nextApplied.get (key))
const before = selectedCountColumns (resource, previous.params, definition)
const after = selectedCountColumns (resource, nextApplied, definition)
const selectionChanged = before.filter (column => column !== 'thumbnail').join (' ')
!== after.filter (column => column !== 'thumbnail').join (' ')
const internal = ['execution_at', 'timezone', 'order', 'nested_json', 'row_numbers']
if (changed.some (key => key === 'columns' ? selectionChanged : !internal.includes (key)))
return nextApplied
// Internal URL enrichment and immediate display changes preserve pending edits.
const next = new URLSearchParams (draft)
for (const key of changed)
{
if (key === 'columns')
{
const columns = selectedCountColumns (resource, draft, definition)
.filter (column => column !== 'thumbnail')
next.set ('columns', (after.includes ('thumbnail')
? ['thumbnail', ...columns] : columns).join (' '))
continue
}
next.delete (key)
for (const value of nextApplied.getAll (key)) next.append (key, value)
}
return next.toString () === draft.toString () ? draft : next
})
}, [appliedState, resource, definition])
const history = draft.get ('definition') === 'history'
const update = (key: string, value: string) => setDraft (previous => {
const next = new URLSearchParams (previous)
if (['rows', 'cols'].includes (key) && next.get ('sort') !== '') next.delete ('sort')
setCountState (next, key, value)
if (key !== 'mode') return next
const resolved = normaliseCountsParams (resource, next, definition)
const axes = [...readCountState<unknown[]> (resolved, 'rows', []),
...readCountState<unknown[]> (resolved, 'cols', [])]
const allowed = value === 'summary'
? ['count', ...axes.map ((_, index) => `a${index}`)]
: fieldsForCounts (resource, definition).map (field => field.key)
if (readCountSort (resolved.get ('sort')).some (sort => !allowed.includes (sort.key)
&& !(value === 'summary' && /^m[0-9]{1,2}$/.test (sort.key))))
next.delete ('sort')
return normaliseCountsParams (resource, next, definition)
})
return <form className="shrink-0" onSubmit={event => {
event.preventDefault ()
if (pending) return
const next = new URLSearchParams (draft)
next.delete ('page')
execute (normaliseCountsParams (resource, next, definition))
}}>
{!history && <div className="flex flex-wrap items-center gap-2 border-b pb-1
dark:border-slate-700">
<div className="flex flex-wrap items-center gap-1 text-sm">
{label ('referenceAt')}<HelpPopover label={message ('referenceAt')}>
{label ('referenceHelp')}</HelpPopover>
<div className="flex min-w-0 max-w-full items-center gap-1">
<DateTimeField precision="second" disabled={countsUseLatest (draft)}
className="min-w-0 flex-1"
value={draft.get ('at') ?? ''}
aria-label={message ('referenceAt').text} lang={message ('referenceAt').language}
onChange={value => update ('at', value ?? '')}/>
<label className="inline-flex shrink-0 items-center gap-1">
<input type="checkbox" checked={countsUseLatest (draft)}
onChange={event => update ('current', event.target.checked ? '1' : '0')}/>
{label ('latest')}</label>
</div>
</div>
</div>}
<div className="flex flex-wrap items-center gap-2 border-b py-1 dark:border-slate-700">
<Button type="submit" size="sm" aria-disabled={pending}
className="aria-disabled:opacity-50">{label ('run')}</Button>
<Button type="button" size="sm" variant="outline" aria-expanded={open}
aria-controls={id} onClick={() => setOpen (!open)}>
{label ('conditions')}<span aria-hidden className="inline-block" style={{
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
transition: `transform ${motion.duration ('collapse')}ms ${motion.easing}` }}>▸</span>
</Button>
<select className={inputClass (false, 'w-auto py-1')}
aria-label={message ('mode').text} lang={message ('mode').language}
value={mode} onChange={event => update ('mode', event.target.value)}>
{['summary', 'details'].map (value => <option key={value} value={value}>
{message (value).text}</option>)}
</select>
{actions}
</div>
<fieldset className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b py-1
dark:border-slate-700">
<legend className="sr-only">{label ('displaySettings')}</legend>
<label className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={draft.get ('row_numbers') === '1'}
onChange={event => {
update ('row_numbers', event.target.checked ? '1' : '0')
displayRowNumbers (event.target.checked)
}}/>{label ('rowNumbers')}
</label>
{details && resource === 'posts' && <label
className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={thumbnail}
onChange={event => displayThumbnail (event.target.checked)}/>
{label ('columns.thumbnailDisplay')}
</label>}
{hasNested && <label className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={draft.get ('nested_json') === '1'}
onChange={event => {
update ('nested_json', event.target.checked ? '1' : '0')
displayJSON (event.target.checked)
}}/>{label ('nested.asJSON')}
</label>}
</fieldset>
<AnimatedCollapse id={id} open={open}>
<div className="max-h-[35dvh] overflow-y-auto overscroll-contain
border-b py-2 dark:border-slate-700 md:max-h-[40dvh]">
<div className="grid gap-3 md:grid-cols-2">
<fieldset className="min-w-0" data-counts-output>
{mode === 'summary' ? <CountsAxes params={draft} update={update}/>
: <>
<legend className="mb-1 font-semibold">{label ('output')}</legend>
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
update (key, value)
if (key === 'order') reorder (value)
}}/>
</>}
</fieldset>
<fieldset className="min-w-0 space-y-2" data-counts-filters>
<legend className="font-semibold">{label ('filters')}</legend>
{history && <div className="flex min-w-0 max-w-full items-center gap-1">
<DateTimeField precision="second" value={draft.get ('from') ?? ''}
aria-label={message ('from').text} lang={message ('from').language}
className="min-w-0 flex-1" onChange={value => update ('from', value ?? '')}/>
<span className="shrink-0">{label ('periodSeparator')}</span>
<DateTimeField precision="second" value={draft.get ('through') ?? ''}
aria-label={message ('through').text} lang={message ('through').language}
className="min-w-0 flex-1" onChange={value => update ('through', value ?? '')}/>
</div>}
<div className="flex flex-wrap gap-2">
{['resource_id', 'user_id', 'limit'].map (key => <FormField key={key}
label={label (key === 'resource_id' ? 'columns.resourceId'
: (key === 'user_id' ? 'fields.actor' : 'fields.limit'))}>
{() => <input value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
: (key === 'user_id' ? 'fields.actor' : 'fields.limit')).text}
className={inputClass (false, 'w-28 py-1')}
onChange={event => update (key, event.target.value)}/>}
</FormField>)}
</div>
{resource === 'posts' && <FormField label={label ('columns.tags')}>
{() => <TagInput includeNico includeUnused value={draft.get ('tags') ?? ''}
setValue={value => update ('tags', value)}/>}
</FormField>}
<CountsAdditionalConditions params={draft} update={update}/>
</fieldset>
</div>
</div>
</AnimatedCollapse>
</form>
}
export default CountsControls
-130
ファイルの表示
@@ -1,130 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import LocalisedText from '@/components/LocalisedText'
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
import { useUITranslation } from '@/lib/useUITranslation'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { LocaleCode } from '@/lib/locales'
export type CountsExportHandle = { cancel: () => Promise<boolean> }
type ExportStatus = {
status: 'starting' | 'queued' | 'running' | 'cancelling' | 'cancelled' | 'ready' | 'failed'
processed: number; total: number | null; token?: string }
const CountsExportProgress = (
{ payload, locale, handle, controls }: {
payload: Record<string, string>; locale: LocaleCode
handle: CountsExportHandle; controls: DialogueFormControls },
) => {
const { label, message } = useUITranslation ('counts')
const [state, setState] = useState<ExportStatus> ({
status: 'starting', processed: 0, total: null })
const [cancelError, setCancelError] = useState (false)
const terminal = useRef (false)
const cancelled = useRef (false)
const job = useRef<string | null> (null)
const creation = useRef<Promise<{ job: string }> | null> (null)
const downloaded = useRef (false)
useEffect (() => {
handle.cancel = async () => {
if (terminal.current) return true
cancelled.current = true
if (job.current != null)
{
try
{
setCancelError (false)
const result = await apiDelete<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (job.current)}`, { locale })
setState (result)
}
catch
{
setCancelError (true)
}
}
return false
}
}, [handle, locale])
useEffect (() => {
let active = true
let timer: ReturnType<typeof setTimeout> | undefined
const poll = async (token: string) => {
try
{
const response = await apiGet<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale })
if (!active) return
const result = (cancelled.current && response.status === 'ready')
? await apiDelete<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale }) : response
if (!active) return
setState (result)
terminal.current = ['ready', 'failed', 'cancelled'].includes (result.status)
if (result.status === 'ready' && result.token != null && !downloaded.current)
{
downloaded.current = true
startAPIDownload ('/counts/export', result.token, () => {
if (active) setState (previous => ({ ...previous, status: 'failed' }))
})
}
if (!terminal.current) timer = setTimeout (() => poll (token), 800)
}
catch (error)
{
if (active)
{
if (isApiError (error) && error.response?.status === 404)
{
terminal.current = true
setState (previous => ({ ...previous, status: 'failed' }))
return
}
setCancelError (true)
timer = setTimeout (() => poll (token), 2000)
}
}
}
creation.current ??= apiPost<{ job: string }> ('/counts/export/jobs', payload, { locale })
creation.current.then (async result => {
if (!active) return
job.current = result.job
if (cancelled.current) await handle.cancel ()
await poll (result.job)
}).catch (() => {
if (active)
{
terminal.current = true
setState (previous => ({ ...previous, status: 'failed' }))
}
})
return () => {
active = false
if (timer != null) clearTimeout (timer)
}
}, [handle, locale, payload])
useEffect (() => {
controls.setActions ([])
}, [controls])
const percent = state.total == null ? null : (state.total === 0 ? 100
: Math.min (100, Math.floor (state.processed / state.total * 100)))
const phase = message (`exportProgress.${state.status}`)
return <div className="space-y-3 py-2">
<p>{label ('exportProgress.format', {
format: message (`exportFormats.${payload.format}`).text })}</p>
<p role="status" aria-live="polite">
<LocalisedText language={phase.language}>{phase.text}</LocalisedText>
</p>
<progress className="h-3 w-full" max={100} value={percent ?? undefined}
aria-label={phase.text} lang={phase.language}/>
<p aria-live="polite">{percent == null
? label ('exportProgress.processed', { count: state.processed })
: label ('exportProgress.measured', {
processed: state.processed, total: state.total ?? 0, percent })}</p>
{cancelError && <p role="alert">{label ('common:fetchError')}</p>}
</div>
}
export default CountsExportProgress
-138
ファイルの表示
@@ -1,138 +0,0 @@
import { useId, useState } from 'react'
import AnimatedCollapse from '@/components/common/AnimatedCollapse'
import SortableList from '@/components/common/SortableList'
import { fieldsForCounts, orderedCountFields,
selectedCountChildren, selectedCountColumns, selectedCountFields } from '@/lib/counts'
import { useCountsDefinition } from '@/lib/countsDefinitions'
import { useClientMotion } from '@/lib/useClientMotion'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountField, CountsResource } from '@/lib/counts'
type BranchProps = {
field: CountField; path: string; enabled: boolean
selected: Set<string>; params: URLSearchParams
select: (path: string, checked: boolean) => void
update: (key: string, value: string) => void }
const FieldBranch = ({ field, path, enabled, selected, params, select, update }: BranchProps) => {
const { label, message } = useUITranslation ('counts')
const [open, setOpen] = useState (false)
const id = useId ()
const motion = useClientMotion ()
const checked = selected.has (path)
return <>
<div className="flex items-center gap-1">
<label className="inline-flex items-center gap-1">
<input type="checkbox" checked={checked} disabled={!enabled || (field.children != null
&& !field.children.some (child => selected.has (`${path}.${child.key}`)))}
onChange={event => select (path, event.target.checked)}/>
{label (field.label)}
</label>
{field.children != null && <button type="button" aria-expanded={open}
aria-controls={id} aria-label={message (field.label).text}
lang={message (field.label).language} onClick={() => setOpen (!open)}
className="rounded px-1 focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-blue-500 dark:focus-visible:ring-blue-400">
<span aria-hidden className="inline-block" style={{
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
transition: `transform ${motion.duration ('collapse')}ms ${motion.easing}` }}>▸</span>
</button>}
</div>
{field.children != null && <AnimatedCollapse open={open} id={id}>
<div className="ml-2 border-l pl-1 dark:border-slate-700">
<FieldGroup fields={field.children} path={path} params={params}
enabled={enabled} selected={selected} select={select} update={update}/>
</div>
</AnimatedCollapse>}
</>
}
const FieldGroup = ({ fields, path, params, enabled, selected, select, update }: {
fields: CountField[]; path: string; params: URLSearchParams
enabled: boolean; selected: Set<string>; select: BranchProps['select']
update: BranchProps['update'] },
) => {
const { message } = useUITranslation ('counts')
const ordered = orderedCountFields (fields, params, path)
const prefix = path ? `${path}.` : ''
return <SortableList groups={{ items: ordered.map (field => field.key) }}
fixed={path === '' ? ['thumbnail'] : []}
name={key => message ('reorder', {
column: message (fields.find (field => field.key === key)!.label).text })}
renderGroup={(_, content) => content}
onChange={groups => {
const current = fields.map (field => `${prefix}${field.key}`)
const other = (params.get ('order') ?? '').split (' ')
.filter (key => key && !current.includes (key))
update ('order', [...other, ...groups.items.map (key => `${prefix}${key}`)].join (' '))
}} renderItem={key => {
const field = fields.find (field => field.key === key)!
return <FieldBranch field={field} path={`${prefix}${key}`} enabled={enabled}
selected={selected} params={params} select={select} update={update}/>
}}/>
}
const CountsFieldTree = (
{ resource, params, update, fields: suppliedFields,
selectionKey = 'columns', parent = '' }: {
resource: CountsResource
fields?: CountField[]
selectionKey?: string
parent?: string
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const definition = useCountsDefinition ()
const fields = suppliedFields ?? fieldsForCounts (resource, definition)
const roots = suppliedFields == null ? selectedCountColumns (resource, params, definition)
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
const selected = selectedCountChildren (params)
const rootPath = (key: string) => parent ? `${parent}.${key}` : key
for (const root of roots) selected.add (rootPath (root))
const select = (path: string, checked: boolean) => {
const root = fields.find (field => rootPath (field.key) === path)
if (root != null)
{
const next = new Set<string> (roots)
if (checked) next.add (root.key)
else next.delete (root.key)
update (selectionKey, fields.filter (field => next.has (field.key))
.map (field => field.key).join (' '))
return
}
if (checked)
{
const parts = path.split ('.')
for (let index = 1; index < parts.length; ++index)
selected.add (parts.slice (0, index + 1).join ('.'))
}
else selected.delete (path)
const nextParams = new URLSearchParams (params)
nextParams.set ('children', [...selected].filter (key => key.includes ('.')).join (' '))
const children = selectedCountChildren (nextParams)
update ('children', [...children].join (' '))
const nextRoots = new Set (roots)
const ancestor = fields.find (field => path.startsWith (`${rootPath (field.key)}.`))
if (checked && ancestor != null) nextRoots.add (ancestor.key)
for (const field of fields)
if (field.children != null && !field.children.some (child =>
children.has (`${rootPath (field.key)}.${child.key}`))) nextRoots.delete (field.key)
update (selectionKey, fields.filter (field => nextRoots.has (field.key))
.map (field => field.key).join (' '))
}
const visibleFields = parent === ''
? fields.filter (field => field.key !== 'thumbnail') : fields
return (
<>
<FieldGroup fields={visibleFields} path={parent} params={params} enabled selected={selected}
select={select} update={update}/>
</>)
}
export default CountsFieldTree
-114
ファイルの表示
@@ -1,114 +0,0 @@
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import { changeCountSort, readCountSort, selectedCountChildren,
writeCountSort, orderedCountFields } from '@/lib/counts'
import { countsLogicalName, countsNestedClipboardTable, countsNestedExport,
countsNestedOrder, countsNestedText, countsNestedIsScalar, countsNestedScalarText }
from '@/lib/countsDisplay'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountField, CountRecord, CountsResource } from '@/lib/counts'
const CountsNestedTable = (
{ rows, fields, path, resource, title, params, update }: {
rows: CountRecord[]
fields: CountField[]
path: string
resource: CountsResource
title: { text: string; language: string }
params: URLSearchParams
update: (key: string, value: string) => void },
) => {
const { message } = useUITranslation ('counts')
const locale = useLocale ()
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
const allSorts = readCountSort (params.get ('child_sort'))
const sorts = allSorts.filter (sort => sort.key.startsWith (`${path}/`))
.map (sort => ({ ...sort, key: sort.key.slice (path.length + 1) }))
const ordered = countsNestedOrder (rows, path, params, locale)
const scalar = (row: CountRecord, field: string) => {
const value = row[field]
if (value == null) return null
if (field === 'tagId' || field === 'tagGroupId')
return <PrefetchLink to={field === 'tagId'
? `/tags/${encodeURIComponent (String (value))}` : '/wiki'}>
{String (value)}</PrefetchLink>
if (field === 'id')
{
if (row.kind === 'external') return String (value)
const target = path === 'parents' ? resource : 'tags'
return <PrefetchLink to={`/${target}/${encodeURIComponent (String (value))}`}>
{String (value)}</PrefetchLink>
}
const display = countsLogicalName (row, field, locale)
if (typeof value === 'boolean')
return <span aria-label={display.text} lang={display.language}>{value ? '✓' : '✗'}</span>
if (display.language != null)
return <LocalisedText language={display.language}>{display.text}</LocalisedText>
if (field === 'name' && typeof row.languageCode === 'string')
return <LocalisedText language={row.languageCode}>{String (value)}</LocalisedText>
return display.text
}
if (countsNestedIsScalar (fields, path, params))
return <span>{countsNestedScalarText (rows, fields, path, params, locale)}</span>
return (
<div className="counts-nested w-full min-w-0 max-w-full">
{params.get ('nested_json') === '1' ? (
<pre data-counts-scroll
aria-label={title.text} lang={title.language}
className="max-h-72 w-full overflow-auto whitespace-pre text-xs">
{JSON.stringify (countsNestedExport (rows, fields, path, params, locale))}
</pre>) : (columns.length > 0 && rows.length > 0 && (
<div data-counts-scroll
className="relative isolate max-h-72 w-full min-w-0 overflow-auto">
<CountsSelectableTable family={path} columns={columns.map (field => field.key)}
identity={`${path}:${params}`} name={title}
copyTable={() => countsNestedClipboardTable (rows, fields, path, params, locale)}
className="text-xs">
<thead className="sticky top-0 z-10 bg-background text-foreground
dark:bg-background dark:text-foreground"><tr>
{columns.map (field => (
<CountsSortHeader key={field.key} field={field.key}
label={message (field.label)}
sorts={sorts} structured={field.children != null}
change={(key, additive) => {
const next = changeCountSort (sorts, key, additive)
.map (sort => ({ ...sort, key: `${path}/${sort.key}` }))
update ('child_sort', writeCountSort ([
...allSorts.filter (sort => !sort.key.startsWith (`${path}/`)),
...next]))
}}/>))}
</tr></thead>
<tbody>{ordered.map ((row, index) => (
<tr key={index} className="border-t dark:border-slate-700">
{columns.map (field => (
<td key={field.key}
className="overflow-hidden break-words border-t px-3 py-2
align-top dark:border-slate-700"
data-counts-copy={
field.children
? countsNestedText (
row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)
: countsLogicalName (row, field.key, locale).text}>
{field.children
? <CountsNestedTable rows={row[field.key] as CountRecord[] ?? []}
fields={field.children} path={`${path}.${field.key}`}
resource={resource}
title={message (field.label)} params={params} update={update}/>
: scalar (row, field.key)}
</td>))}
</tr>))}</tbody>
</CountsSelectableTable>
</div>))}
</div>)
}
export default CountsNestedTable
-93
ファイルの表示
@@ -1,93 +0,0 @@
import { useContext, useEffect, useRef, useState } from 'react'
import { CountsTableScope, MAX_COUNT_WIDTH, MIN_COUNT_WIDTH,
useCountsTableLayout } from '@/lib/countsTableLayout'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
const CountsResizeHandle = ({ field, label }: {
field: string; label: { text: string; language: string } }) => {
const family = useContext (CountsTableScope)
const layout = useCountsTableLayout ()
const { message } = useUITranslation ('counts')
const [active, setActive] = useState (false)
const press = useRef<{ x: number; y: number; width: number; armed: boolean
timer?: ReturnType<typeof setTimeout> } | null> (null)
const saved = useRef<{ cursor: string; userSelect: string } | null> (null)
const restore = () => {
if (saved.current == null) return
document.body.style.cursor = saved.current.cursor
document.body.style.userSelect = saved.current.userSelect
saved.current = null
}
const finish = () => {
if (press.current?.timer != null) clearTimeout (press.current.timer)
press.current = null
setActive (false)
restore ()
}
const arm = () => {
if (press.current == null) return
press.current.armed = true
saved.current = { cursor: document.body.style.cursor,
userSelect: document.body.style.userSelect }
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
setActive (true)
}
useEffect (() => () => {
if (press.current?.timer != null) clearTimeout (press.current.timer)
if (saved.current != null)
{
document.body.style.cursor = saved.current.cursor
document.body.style.userSelect = saved.current.userSelect
}
}, [])
const name = message ('resize.column', { column: label.text })
return (
<span role="separator" tabIndex={0} aria-orientation="vertical"
aria-label={name.text} lang={name.language}
aria-valuenow={layout.width (family, field)} aria-valuemin={MIN_COUNT_WIDTH}
aria-valuemax={MAX_COUNT_WIDTH} title={name.text}
data-counts-resize
className={cn ('absolute inset-y-0 right-0 z-30 w-3 cursor-col-resize touch-none',
'select-none outline-offset-[-2px] focus-visible:outline',
'before:absolute before:inset-y-1 before:left-1/2 before:border-l',
'before:border-slate-300 dark:before:border-slate-600',
'hover:before:border-blue-500 dark:hover:before:border-blue-400',
active && 'before:border-blue-500 dark:before:border-blue-400')}
onPointerDown={event => {
event.stopPropagation ()
if (!event.isPrimary || event.button !== 0) return
event.preventDefault ()
finish ()
event.currentTarget.setPointerCapture (event.pointerId)
press.current = { x: event.clientX, y: event.clientY,
width: layout.width (family, field), armed: false }
if (event.pointerType === 'mouse') arm ()
else press.current.timer = setTimeout (arm, 400)
}}
onPointerMove={event => {
event.stopPropagation ()
const state = press.current
if (state == null) return
if (state.armed)
layout.resize (family, field, state.width + event.clientX - state.x)
else if (Math.hypot (event.clientX - state.x, event.clientY - state.y) > 8)
finish ()
}}
onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={finish}
onClick={event => { event.preventDefault (); event.stopPropagation () }}
onContextMenu={event => event.preventDefault ()}
onKeyDown={event => {
event.stopPropagation ()
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault ()
layout.resize (family, field, layout.width (family, field)
+ (event.key === 'ArrowLeft' ? -10 : 10))
}}/>)
}
export default CountsResizeHandle
-164
ファイルの表示
@@ -1,164 +0,0 @@
import SortableColumns from '@/components/common/SortableColumns'
import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
import CountsRowNumber from '@/components/counts/CountsRowNumber'
import CountsNestedTable from '@/components/counts/CountsNestedTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts,
readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
import { countsCellCopy, countsCellText, countsDetailClipboardTable,
countsImageURL, countsNestedRows } from '@/lib/countsDisplay'
import useDialogue from '@/lib/dialogues/useDialogue'
import { useLocale } from '@/lib/useLocale'
import { useCountsDefinition } from '@/lib/countsDefinitions'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountColumn, CountsResource,
CountsResult, CountsVersion } from '@/lib/counts'
const CountsResults = (
{ resource, data, params, pending, update }: {
resource: CountsResource
data: CountsResult
params: URLSearchParams
pending: boolean
update: (key: string, value: string) => void },
) => {
const definition = useCountsDefinition ()
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const dialogue = useDialogue ()
const columns = selectedCountColumns (resource, params, definition)
const rowNumbers = params.get ('row_numbers') === '1'
const fields = fieldsForCounts (resource, definition)
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
const sort = (key: string, additive: boolean) =>
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
const cell = (row: CountsVersion, column: CountColumn) => {
const field = fields.find (field => field.key === column)!
const value = countsCellText (row, column, locale, params)
if (field.children != null)
{
return <CountsNestedTable rows={countsNestedRows (row, column)}
fields={field.children} path={column} resource={resource}
title={message (field.label)} params={params} update={update}/>
}
switch (column)
{
case 'resource_id':
if (resource === 'nico_tags') return row.name ? (
<PrefetchLink to={`/tags/nico?${new URLSearchParams ({ name: row.name })}`}>
{row.resourceId}</PrefetchLink>) : row.resourceId
if (resource === 'tag_groups') return <PrefetchLink
to={`/wiki/${encodeURIComponent (`タググループ:${row.name ?? ''}`)}`}>
{row.resourceId}</PrefetchLink>
return <PrefetchLink to={`/${resource === 'tag_descriptions' ? 'tags' : resource}/${
encodeURIComponent (row.resourceId)}`}>
{row.resourceId}</PrefetchLink>
case 'name':
return resource === 'nico_tags' && row.name ? (
<PrefetchLink to={`/tags/nico?${new URLSearchParams ({ name: row.name })}`}>
{value}</PrefetchLink>) : value
case 'thumbnail':
{
const url = countsImageURL (row.thumbnailBase)
return url == null ? null : (
<button type="button" className="block" aria-label={message (
'shared:labels.thumbnail').text}
onClick={event => {
const trigger = event.currentTarget
void dialogue.form ({
title: label ('shared:labels.thumbnail'),
cancelText: label ('shared:dialogue.close'),
size: 'viewport',
body: () => <img src={url} alt={message ('shared:labels.thumbnail').text}
referrerPolicy="no-referrer" className="h-full w-full object-contain"/>
}).then (() => trigger.focus ({ preventScroll: true }))
}}>
<PostThumbnailPreview url={url} className="h-10 w-16"
alt={message ('shared:labels.thumbnail').text} referrerPolicy="no-referrer"/>
</button>)
}
case 'url':
case 'thumbnail_base':
return <a href={countsImageURL (value) ?? undefined} target="_blank" rel="noreferrer">
{value}</a>
case 'is_latest':
return <LocalisedText language={message ('latestValue').language}>
{value}</LocalisedText>
case 'user':
return row.createdByUser?.name ? value : (
<LocalisedText language={message (
row.createdByUser == null ? 'bot' : 'history:anonymous').language}>
{value}</LocalisedText>)
case 'event_type':
return <LocalisedText language={message (`events.${row.eventType}`).language}>
{value}</LocalisedText>
case 'category':
return <LocalisedText language={message (`common:categories.${row.category}`).language}>
{value}</LocalisedText>
default: return value
}
}
return <SortableColumns ids={columns} fixed={['thumbnail']}
name={key => message (fields.find (field => field.key === key)!.label)}
onChange={next => {
const all = fields.map (field => field.key)
const other = (params.get ('order') ?? '').split (' ').filter (key => !all.includes (key))
update ('order', [...next, ...all.filter (key => !next.includes (key)), ...other].join (' '))
}}>{columns => {
const tableColumns = rowNumbers ? ['row_number', ...columns] : columns
return (
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
{tableColumns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
<CountsSelectableTable family={resource}
columns={tableColumns}
copyTable={() => countsDetailClipboardTable (data.versions ?? [],
columns.map (column => fields.find (field => field.key === column)!),
params, locale, rowNumbers ? (data.page - 1) * data.limit + 1 : null)}
identity={`${resource}:${countsRequestParams (params)}:${tableColumns.join (' ')}:${
(data.versions ?? []).map (row => row.id)}`}
className="text-left text-sm">
<thead className="sticky top-0 z-20 bg-background text-foreground
dark:bg-background dark:text-foreground"><tr>
{rowNumbers && <CountsRowNumber/>}
{columns.map (column => column === 'thumbnail'
? <th key={column} data-counts-heading={message (countColumns[column]).text}
className="relative px-2 py-1">
<span className="sr-only">{label (countColumns[column])}</span>
<CountsResizeHandle field={column} label={message (countColumns[column])}/>
</th>
: <CountsSortHeader key={column} field={column}
label={message (fields.find (field => field.key === column)!.label)}
dragId={column}
sorts={sorts} change={sort} structured={fields.find (
field => field.key === column)?.children != null}/>)}
</tr></thead>
<tbody>
{(data.versions ?? []).map ((row, index) => (
<tr key={row.id} className="border-b dark:border-slate-700">
{rowNumbers && <CountsRowNumber
value={(data.page - 1) * data.limit + index + 1}/>}
{columns.map (column => (
<td key={column}
className="overflow-hidden break-words border-b px-2 py-1
align-top dark:border-slate-700"
data-counts-copy={countsCellCopy (
row,
fields.find (field => field.key === column)!, params, locale)}>
{cell (row, column)}</td>))}
</tr>))}
</tbody>
</CountsSelectableTable>)}
{(data.versions?.length ?? 0) === 0
&& <p className="p-2">{label ('empty')}</p>}
</div>)
}}</SortableColumns>
}
export default CountsResults
-17
ファイルの表示
@@ -1,17 +0,0 @@
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
import { useUITranslation } from '@/lib/useUITranslation'
const CountsRowNumber = ({ value, rowSpan }: { value?: number; rowSpan?: number }) => {
const { label, message } = useUITranslation ('counts')
if (value != null) return <td data-counts-copy={String (value)}
className="border-b px-2 py-1 text-right align-top tabular-nums dark:border-slate-700">
{value}</td>
return <th rowSpan={rowSpan} data-counts-heading={message ('rowNumberHeading').text} className="relative px-2 py-1">
{label ('rowNumberHeading')}
<CountsResizeHandle field="row_number" label={message ('rowNumberHeading')}/>
</th>
}
export default CountsRowNumber
-354
ファイルの表示
@@ -1,354 +0,0 @@
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import CountsCellMenu from '@/components/counts/CountsCellMenu'
import { countsClipboard, writeCountsClipboard } from '@/lib/countsClipboard'
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { ClipboardEvent, ReactNode } from 'react'
import type { CountsClipboardTable } from '@/lib/countsClipboard'
import type { CountsMenuAction } from '@/components/counts/CountsCellMenu'
type Point = { row: number; column: number }
type Selection = { identity: string; anchor: Point; end: Point }
const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
const 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) =>
cellGrid (table)[point.row]?.[point.column]
const CountsSelectableTable = (
{ family, columns, identity, children, className, name, copyTable }: {
family: string
columns: string[]
identity: string
children: ReactNode
className?: string
name?: { text: string; language: string }
copyTable?: () => CountsClipboardTable },
) => {
const id = useId ()
const root = useRef<HTMLTableElement> (null)
const dragging = useRef (false)
const touchArmed = useRef (false)
const touchPress = useRef<{ x: number; y: number
timer: ReturnType<typeof setTimeout> } | null> (null)
const [menu, setMenu] = useState<{ x: number; y: number; point: Point
identity: string } | null> (null)
const suppressClick = useRef (false)
const [stored, setStored] = useState<Selection | null> (null)
const selection = stored?.identity === identity ? stored : null
const layout = useCountsTableLayout ()
const { label, message } = useUITranslation ('counts')
const bounds = useMemo (() => selection == null ? null : {
top: Math.min (selection.anchor.row, selection.end.row),
bottom: Math.max (selection.anchor.row, selection.end.row),
left: Math.min (selection.anchor.column, selection.end.column),
right: Math.max (selection.anchor.column, selection.end.column) }, [selection])
const finish = () => {
dragging.current = false
touchArmed.current = false
if (touchPress.current != null) clearTimeout (touchPress.current.timer)
touchPress.current = null
}
useEffect (() => {
const table = root.current
const preventPan = (event: TouchEvent) => {
if (touchArmed.current) event.preventDefault ()
}
table?.addEventListener ('touchmove', preventPan, { passive: false })
return () => {
table?.removeEventListener ('touchmove', preventPan)
if (touchPress.current != null) clearTimeout (touchPress.current.timer)
}
}, [])
useLayoutEffect (() => {
if (root.current == null) return
const grid = cellGrid (root.current)
const assigned = new Set<HTMLTableCellElement> ()
for (const [row, cells] of grid.entries ())
{
for (const [column, cell] of cells.entries ())
{
if (assigned.has (cell)) continue
assigned.add (cell)
const selected = bounds != null && row <= bounds.bottom
&& row + cell.rowSpan - 1 >= bounds.top && column <= bounds.right
&& column + cell.colSpan - 1 >= bounds.left
cell.id = `${id}-${row}-${column}`
if (cell.getAttribute ('role') !== 'rowheader') cell.setAttribute ('role', 'gridcell')
cell.setAttribute ('aria-selected', String (selected))
if (selected) cell.dataset.countsSelected = ''
else delete cell.dataset.countsSelected
}
}
if (bounds != null)
root.current.setAttribute ('aria-activedescendant', grid[bounds.bottom]?.[bounds.right]?.id ?? '')
else root.current.removeAttribute ('aria-activedescendant')
}, [bounds, children, id])
const pointFor = (element: Element | null, y?: number): Point | null => {
const table = root.current
if (table == null || element == null || !table.contains (element)) return null
let cell = element.closest ('td')
while (cell != null && cell.closest ('table') !== table)
cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null
if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null
const grid = cellGrid (table)
const hitRow = y == null ? -1 : Array.from (table.tBodies[0].rows).findIndex (row => {
const rect = row.getBoundingClientRect ()
return y >= rect.top && y < rect.bottom
})
for (const [row, cells] of grid.entries ())
{
if (hitRow >= 0 && row !== hitRow) continue
const column = cells.indexOf (cell as HTMLTableCellElement)
if (column >= 0) return { row, column }
}
return null
}
const closeMenu = useCallback (() => {
setMenu (null)
root.current?.focus ({ preventScroll: true })
}, [])
const openMenu = (point: Point, x: number, y: number) => {
const inside = bounds != null && point.row >= bounds.top && point.row <= bounds.bottom
&& point.column >= bounds.left && point.column <= bounds.right
if (inside) setStored (selection)
else setStored ({ identity, anchor: point, end: point })
setMenu ({ point, x, y, identity })
}
const clipboard = (headers = false) => {
if (bounds == null || root.current == null) return null
if (copyTable != null)
{
const table = copyTable ()
return countsClipboard ({
columns: table.columns.slice (bounds.left, bounds.right + 1),
headerRows: table.headerRows?.map (row => row.slice (bounds.left, bounds.right + 1)),
rows: table.rows.slice (bounds.top, bounds.bottom + 1)
.map (row => row.slice (bounds.left, bounds.right + 1)) }, headers)
}
const rows: string[][] = []
for (let row = bounds.top; row <= bounds.bottom; ++row)
{
const values: string[] = []
for (let column = bounds.left; column <= bounds.right; ++column)
{
const cell = cellAt (root.current, { row, column })
values.push (cell?.dataset.countsCopy ?? cell?.innerText ?? '')
}
rows.push (values)
}
if (headers)
{
const heading = root.current.tHead?.rows[0]
rows.unshift (Array.from ({ length: bounds.right - bounds.left + 1 }, (entry, index) => {
void entry
const cell = heading?.cells[bounds.left + index]
return cell?.dataset.countsHeading ?? cell?.textContent ?? ''
}))
}
return countsClipboard (rows, headers)
}
const menuAction = (action: CountsMenuAction) => {
if (menu == null || root.current == null) return
if (action === 'copy' || action === 'copyHeaders')
{
const payload = clipboard (action === 'copyHeaders')
if (payload != null) void writeCountsClipboard (payload).catch (error => {
console.error (error)
})
}
else
{
const lastRow = (root.current.tBodies[0]?.rows.length ?? 1) - 1
const lastColumn = columns.length - 1
const anchor = {
row: action === 'row' ? menu.point.row : 0,
column: action === 'column' ? menu.point.column : 0 }
const end = {
row: action === 'row' ? menu.point.row : lastRow,
column: action === 'column' ? menu.point.column : lastColumn }
setStored ({ identity, anchor, end })
}
closeMenu ()
}
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
if (document.activeElement !== root.current) return
const payload = clipboard ()
if (payload == null) return
event.preventDefault ()
event.stopPropagation ()
event.clipboardData.setData ('text/plain', payload.text)
try
{
event.clipboardData.setData ('text/html', payload.html)
}
catch
{
// Plain TSV remains available when the browser disallows HTML clipboard data.
}
}
const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0)
return (
<CountsTableScope.Provider value={family}>
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
aria-label={name?.text ?? message ('selection.table').text}
aria-describedby={`${id}-hint`}
style={{ tableLayout: 'fixed', width: totalWidth }}
className={cn ('counts-grid select-none border-separate border-spacing-0 outline-none',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500',
'dark:focus-visible:outline-blue-400',
'[&>tbody>tr>td[data-counts-selected]]:bg-blue-100',
'[&>tbody>tr>td[data-counts-selected]]:text-slate-900',
'dark:[&>tbody>tr>td[data-counts-selected]]:bg-blue-900',
'dark:[&>tbody>tr>td[data-counts-selected]]:text-slate-100',
'[&>tbody>tr>td[data-counts-selected]]:outline',
'[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
'[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
onCopy={copy}
onClickCapture={event => {
if ((event.target as Element).closest ('table') !== event.currentTarget
|| !suppressClick.current) return
event.preventDefault ()
event.stopPropagation ()
suppressClick.current = false
}}
onPointerDown={event => {
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
suppressClick.current = false
if (element.closest (interactive) != null || event.button !== 0) return
if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll'))
{
const rect = element.getBoundingClientRect ()
if (event.clientX >= rect.left + element.clientLeft + element.clientWidth
|| event.clientY >= rect.top + element.clientTop + element.clientHeight) return
}
const point = pointFor (element, event.clientY)
if (point == null) return
event.stopPropagation ()
setMenu (null)
event.currentTarget.focus ({ preventScroll: true })
setStored ({ identity, anchor: event.shiftKey && selection != null
? selection.anchor : point, end: point })
finish ()
if (event.pointerType === 'mouse')
{
event.preventDefault ()
dragging.current = true
event.currentTarget.setPointerCapture (event.pointerId)
}
else
{
const table = event.currentTarget
const pointerId = event.pointerId
touchPress.current = { x: event.clientX, y: event.clientY,
timer: setTimeout (() => {
suppressClick.current = true
openMenu (point, event.clientX, event.clientY)
dragging.current = true
touchArmed.current = true
table.setPointerCapture (pointerId)
}, 450) }
}
}}
onPointerMove={event => {
if (touchPress.current != null && !touchArmed.current && Math.hypot (
event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8)
finish ()
if (!dragging.current || event.buttons === 0) return
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY), event.clientY)
if (point == null) return
if (touchArmed.current && touchPress.current != null
&& Math.hypot (event.clientX - touchPress.current.x,
event.clientY - touchPress.current.y) <= 8) return
setMenu (null)
event.stopPropagation ()
setStored (previous => previous?.identity !== identity ? previous : {
...previous, end: point })
}}
onPointerUp={finish} onPointerCancel={finish}
onLostPointerCapture={event => {
if (event.target === event.currentTarget) finish ()
}}
onContextMenu={event => {
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
const point = pointFor (element, event.clientY)
if (point == null || element.closest ('[data-counts-resize]') != null) return
event.preventDefault ()
event.stopPropagation ()
openMenu (point, event.clientX, event.clientY)
}}
onKeyDown={event => {
if (event.target !== event.currentTarget) return
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10'))
{
const point = selection?.end ?? { row: 0, column: 0 }
const rect = cellAt (event.currentTarget, point)?.getBoundingClientRect ()
if (rect != null)
{
event.preventDefault ()
openMenu (point, rect.left, rect.bottom)
}
return
}
if (event.key === 'Escape')
{
setStored (null)
return
}
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
const rows = event.currentTarget.tBodies[0]?.rows
if (rows == null || rows.length === 0) return
event.preventDefault ()
const previous = selection?.end ?? { row: 0, column: 0 }
const key = selection == null ? '' : event.key
const point = {
row: Math.max (0, Math.min (rows.length - 1, previous.row
+ (key === 'ArrowDown' ? 1 : (key === 'ArrowUp' ? -1 : 0)))),
column: Math.max (0, Math.min (columns.length - 1, previous.column
+ (key === 'ArrowRight' ? 1 : (key === 'ArrowLeft' ? -1 : 0)))) }
setStored ({ identity, anchor: event.shiftKey && selection != null
? selection.anchor : point, end: point })
cellAt (event.currentTarget, point)?.scrollIntoView ({
block: 'nearest', inline: 'nearest' })
}}>
<caption id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</caption>
<colgroup>{columns.map (key => (
<col key={key} style={{ width: layout.width (family, key) }}/>))}</colgroup>
{children}
</table>
{menu?.identity === identity && <CountsCellMenu x={menu.x} y={menu.y}
act={menuAction} close={closeMenu}/>}
</CountsTableScope.Provider>)
}
export default CountsSelectableTable
-107
ファイルの表示
@@ -1,107 +0,0 @@
import { useEffect, useRef } from 'react'
import SortableItem from '@/components/common/SortableItem'
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
import { Button } from '@/components/ui/button'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountSort } from '@/lib/counts'
const CountsSortHeader = (
{ field, label, sorts, change, structured = false, rowSpan, colSpan, resizable = true, dragId, totalKind }: {
field: string
label: { text: string; language: string }
sorts: CountSort[]
change: (key: string, additive: boolean) => void
structured?: boolean
rowSpan?: number; colSpan?: number; resizable?: boolean
dragId?: string; totalKind?: string },
) => {
const { message } = useUITranslation ('counts')
const press = useRef<{ x: number; y: number
timer: ReturnType<typeof setTimeout> } | null> (null)
const suppressClick = useRef (false)
const cancel = () => {
if (press.current != null) clearTimeout (press.current.timer)
press.current = null
}
useEffect (() => () => {
if (press.current != null) clearTimeout (press.current.timer)
}, [])
const priority = sorts.findIndex (sort => sort.key === field)
const direction = sorts[priority]?.direction
const state = priority < 0 ? 'none' : (direction === 'asc' ? 'ascending' : 'descending')
const title = message (structured ? 'sort.structured' : 'sort.action')
const aria = message (priority < 0 ? 'sort.unsortedLabel'
: (sorts.length === 1 ? 'sort.singleLabel' : 'sort.label'), {
column: label.text, priority: priority + 1,
direction: message (`sort.${state}`).text })
const render = (shell?: Parameters<NonNullable<
Parameters<typeof SortableItem>[0]['renderShell']>>[0]) => (
<th ref={shell?.ref} style={shell?.style} data-counts-total={totalKind}
data-counts-heading={label.text} rowSpan={rowSpan} colSpan={colSpan}
className="relative overflow-hidden whitespace-nowrap px-1 py-1"
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
<div className="flex min-w-0 items-center">
{shell?.handle}
<Button type="button" variant="ghost" size="sm"
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
lang={aria.language} aria-label={aria.text} title={title.text}
onPointerDown={event => {
if (!event.isPrimary)
{
cancel ()
return
}
suppressClick.current = false
if (event.pointerType !== 'touch' && event.pointerType !== 'pen')
return
cancel ()
const x = event.clientX
const y = event.clientY
press.current = { x, y, timer: setTimeout (() => {
press.current = null
suppressClick.current = true
change (field, true)
}, 500) }
}}
onPointerMove={event => {
if (press.current != null && Math.hypot (
event.clientX - press.current.x, event.clientY - press.current.y) > 8)
cancel ()
}}
onPointerUp={cancel} onPointerCancel={cancel} onPointerLeave={cancel}
onContextMenu={event => {
if (press.current != null || suppressClick.current)
event.preventDefault ()
}}
onKeyDown={event => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault ()
suppressClick.current = false
change (field, event.ctrlKey || event.metaKey)
}}
onClick={event => {
if (suppressClick.current)
{
suppressClick.current = false
event.preventDefault ()
return
}
change (field, event.ctrlKey || event.metaKey)
}}>
<span className="truncate" lang={label.language}>{label.text}</span>
{priority >= 0 && <span aria-hidden>
{direction === 'asc' ? '▲' : '▼'}{sorts.length > 1 && priority + 1}</span>}
</Button>
</div>
{resizable && <CountsResizeHandle field={field} label={label}/>}
</th>)
return dragId == null ? render () : <SortableItem id={dragId} name={message ('reorder', { column: label.text })}
handleId={`column-${dragId}`} moveGroup={() => { }} renderShell={render}>{null}</SortableItem>
}
export default CountsSortHeader
-300
ファイルの表示
@@ -1,300 +0,0 @@
import SortableColumns from '@/components/common/SortableColumns'
import { countsSummaryValue } from '@/lib/countsDisplay'
import PrefetchLink from '@/components/PrefetchLink'
import CountsRowNumber from '@/components/counts/CountsRowNumber'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
import { countsAxisLabel, useCountsDefinition }
from '@/lib/countsDefinitions'
import { cn } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { queryRecordParams, setQueryRecords, readCountState, encodeCountState, setCountState } from '@/lib/queryState'
import type { CountsPivotHeader, CountsResource, CountsResult } from '@/lib/counts'
import type { CountsAxis } from '@/lib/countsDefinitions'
const CountsSummaryTable = (
{ resource, data, params, pending, update, dragIds = {} }: {
dragIds?: Record<string, string>
resource: CountsResource; data: CountsResult; params: URLSearchParams
pending: boolean; update: (key: string, value: string, extra?: Record<string, string>) => void },
) => {
const definition = useCountsDefinition ()
const locale = useLocale ()
const { message } = useUITranslation ('counts')
const numbered = params.get ('row_numbers') === '1'
const rows = data.rows ?? []
const cols = data.cols ?? []
const cells = data.cells ?? []
const rowHeaders: CountsPivotHeader[] = data.rowHeaders ?? [...new Set (
cells.map (cell => JSON.stringify (cell.values.slice (0, rows.length))))]
.map (key => ({ values: JSON.parse (key), kind: 'data' }))
const columnHeaders: CountsPivotHeader[] = data.columnHeaders
?? (data.columnValues ?? [[]]).map (values => ({ values, kind: 'data' }))
const sorts = readCountSort (params.get ('sort'))
const change = (key: string, additive: boolean) => update (
'sort', writeCountSort (changeCountSort (sorts, key, additive)))
const display = (value: string | number | null, axis: CountsAxis, index: number) =>
countsSummaryValue (value, axis, index, data, locale)
const aggregateLabel = (header: CountsPivotHeader) => message (
header.kind === 'grand_total' ? (rows.length + cols.length === 1
? 'singleTotal' : 'grandTotal') : 'subtotal').text
const leading = Math.max (rows.length, cols.length > 0 ? 1 : 0)
const levels = Math.max (1, cols.length)
const headingLevels = cols.length === 0 ? 1 : levels * 2
const names = (header: CountsPivotHeader, axes: CountsAxis[], offset: number) =>
header.values.map ((value, index) => display (value, axes[index], offset + index))
const columnKey = (header: CountsPivotHeader) => `${header.kind}:${JSON.stringify (header.values)}`
const measures = readCountState<Record<string, string | number | null>[]> (
params, 'measures', [])
const measureKey = (header: CountsPivotHeader) => {
if (header.values.length === 0) return 'count'
const ids = [...new Set (measures.map (entry => String (entry.key)))]
const existing = ids.find (id => JSON.stringify (measures.filter (entry => entry.key === id)
.sort ((a, b) => Number (a.axis) - Number (b.axis)).map (entry => entry.value))
=== JSON.stringify (header.values))
return existing ?? `m${Array.from ({ length: 100 }, (_, index) => index)
.find (index => !ids.includes (`m${index}`)) ?? 99}`
}
const changeMeasure = (header: CountsPivotHeader, additive: boolean) => {
const key = measureKey (header)
const next = new URLSearchParams (params)
const nextSort = changeCountSort (sorts, key, additive)
const active = new Set (nextSort.map (sort => sort.key))
const records = measures.filter (record => record.key !== key && active.has (String (record.key)))
setQueryRecords (next, 'measures', [...records, ...(active.has (key)
? header.values.map ((value, axis) => ({ key, axis, value })) : [])])
update ('sort', writeCountSort (nextSort),
queryRecordParams (next, 'measures'))
}
const keys = [...(numbered ? ['row_number'] : []),
...Array.from ({ length: leading }, (_, index) => `a${index}`),
...columnHeaders.map (columnKey)]
const full = new Map (cells.map (cell => [JSON.stringify (cell.values), cell.count]))
const totals = new Map ((data.pivotTotals ?? []).map (cell => [
JSON.stringify ([cell.row, cell.column]), cell.count]))
const count = (row: CountsPivotHeader, column: CountsPivotHeader) =>
(row.values.length === rows.length && column.values.length === cols.length)
? full.get (JSON.stringify ([...row.values, ...column.values])) ?? 0
: totals.get (JSON.stringify ([row.values, column.values])) ?? 0
const drill = (row: CountsPivotHeader, column: CountsPivotHeader) => {
const next = new URLSearchParams (params)
const existing = readCountState<Record<string, string | number | null>[]> (params, 'drill', [])
setCountState (next, 'drill', encodeCountState ('drill', [...existing,
...row.values.map ((value, index) => ({ ...rows[index], value })),
...column.values.map ((value, index) => ({ ...cols[index], value }))]))
next.set ('mode', 'details')
next.delete ('sort')
next.delete ('page')
return `/counts/${resource}?${next}`
}
let ordinal = (data.page - 1) * data.limit
const rowLabels = rowHeaders.map (header => {
const labels = names (header, rows, 0)
const number = header.kind === 'data' ? String (++ordinal) : ''
if (header.kind !== 'data') labels.push (aggregateLabel (header))
return [...(numbered ? [number] : []),
...Array.from ({ length: leading }, (_, index) => labels[index] ?? '')]
})
const spans = rowHeaders.map ((row, index) => rows.map ((_, axis) => {
if (row.kind !== 'data') return 1
const prefix = JSON.stringify (row.values.slice (0, axis + 1))
const previous = rowHeaders[index - 1]
if (previous?.kind === 'data'
&& JSON.stringify (previous.values.slice (0, axis + 1)) === prefix) return 0
let span = 1
while (index + span < rowHeaders.length)
{
const next = rowHeaders[index + span]
if (next.kind !== 'data'
|| JSON.stringify (next.values.slice (0, axis + 1)) !== prefix) break
++span
}
return span
}))
rowLabels.forEach ((labels, row) => spans[row].forEach ((span, column) => {
if (span === 0) labels[column + (numbered ? 1 : 0)] = ''
}))
// This unmerged matrix is also the single source for TSV and HTML clipboard headings.
const valueHeadingRows = Array.from ({ length: levels }, (_, level) => [
...(numbered ? [level === 0 ? message ('rowNumberHeading').text : ''] : []),
...Array.from ({ length: leading }, (_, index) =>
level === 0 && rows[index] != null
? countsAxisLabel (rows[index], definition, locale).text : ''),
...columnHeaders.map ((header, index) => {
if (cols.length === 0) return message ('count').text
if (level > header.values.length) return ''
if (level === header.values.length)
return header.kind === 'data' ? '' : aggregateLabel (header)
const prefix = header.values.slice (0, level + 1)
const previous = columnHeaders[index - 1]
if (level < levels - 1 && previous != null
&& JSON.stringify (previous.values.slice (0, level + 1)) === JSON.stringify (prefix))
return ''
return display (header.values[level], cols[level], rows.length + level)
})])
const axisHeadingRows = cols.map ((axis, level) => [
...(numbered ? [''] : []), ...Array.from ({ length: leading }, () => ''),
...columnHeaders.map ((header, index) => {
if (header.values.length < level) return ''
const previous = columnHeaders[index - 1]
const prefix = JSON.stringify (header.values.slice (0, level))
return (previous != null && previous.values.length >= level
&& JSON.stringify (previous.values.slice (0, level)) === prefix)
? '' : countsAxisLabel (axis, definition, locale).text
})])
const headingRows = cols.length === 0 ? valueHeadingRows
: axisHeadingRows.flatMap ((headings, level) => [headings, valueHeadingRows[level]])
if (cols.length > 0)
{
for (let index = 0; index < leading + (numbered ? 1 : 0); ++index)
{
headingRows[0][index] = valueHeadingRows[0][index]
headingRows[1][index] = ''
}
}
const axisHeaderCells = (level: number) => {
const output = []
for (let index = 0; index < columnHeaders.length; ++index)
{
const header = columnHeaders[index]
if (header.values.length < level) continue
const prefix = JSON.stringify (header.values.slice (0, level))
let span = 1
while (index + span < columnHeaders.length)
{
const sibling = columnHeaders[index + span]
if (sibling.values.length < level
|| JSON.stringify (sibling.values.slice (0, level)) !== prefix) break
++span
}
const name = countsAxisLabel (cols[level], definition, locale)
output.push (<th key={index} colSpan={span} scope="colgroup"
lang={name.language} className="px-2 py-1">{name.text}</th>)
index += span - 1
}
return output
}
const matrix = rowHeaders.map ((header, index) => [...rowLabels[index],
...columnHeaders.map (column => String (count (header, column)))])
const headerCells = (level: number) => {
const output = []
for (let index = 0; index < columnHeaders.length; ++index)
{
const header = columnHeaders[index]
if (cols.length > 0 && level > header.values.length) continue
const aggregate = header.kind !== 'data' && level === header.values.length
const leaf = level === levels - 1 || aggregate
const text =
cols.length === 0
? message ('count').text
: (aggregate ? aggregateLabel (header)
: display (header.values[level], cols[level], rows.length + level))
let span = 1
if (!leaf)
{
const prefix = JSON.stringify (header.values.slice (0, level + 1))
while (index + span < columnHeaders.length)
{
const sibling = columnHeaders[index + span]
if (sibling.values.length <= level
|| JSON.stringify (sibling.values.slice (0, level + 1)) !== prefix) break
++span
}
}
const sortKey = leaf ? measureKey (header) : `a${rows.length + level}`
const field = leaf ? columnKey (header) : sortKey
output.push (<CountsSortHeader key={`${level}:${field}:${index}`} field={field}
colSpan={span} resizable={leaf}
rowSpan={(aggregate && cols.length > 0) ? (levels - level) * 2 - 1 : 1}
totalKind={header.kind !== 'data' ? header.kind : undefined}
dragId={level === 0 && header.values.length > 0
? dragIds[JSON.stringify (header.values.slice (0, 1))] : undefined}
label={{ text, language: message ('count').language }}
sorts={sorts.map (sort => sort.key === sortKey ? { ...sort, key: field } : sort)}
change={(_, additive) => leaf ? changeMeasure (header, additive)
: change (sortKey, additive)}/>)
index += span - 1
}
return output
}
return <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
<CountsSelectableTable family={`pivot_${resource}`} columns={keys}
copyTable={() => ({ columns: keys.map ((_, index) => ({ label: headingRows[0][index] })),
headerRows: headingRows, rows: matrix })}
identity={`${countsRequestParams (params)}:${JSON.stringify (keys)}`}
className="text-left text-sm">
<thead className="sticky top-0 z-20">
{headingRows.map ((_, tier) => <tr key={tier}>
{tier === 0 && <>
{numbered && <CountsRowNumber rowSpan={headingLevels}/>}
{Array.from ({ length: leading }, (_, index) => rows[index] == null
? <th key={index} rowSpan={headingLevels}/>
: <CountsSortHeader key={rows[index].field} field={`a${index}`}
rowSpan={headingLevels}
label={countsAxisLabel (rows[index], definition, locale)}
sorts={sorts} change={change}/>)}
</>}
{(cols.length > 0 && tier % 2 === 0)
? axisHeaderCells (tier / 2) : headerCells (Math.floor (tier / 2))}
</tr>)}
</thead>
<tbody>{rowHeaders.map ((row, index) => <tr key={columnKey (row)}>
{rowLabels[index].map ((value, offset) => {
const axis = offset - (numbered ? 1 : 0)
const span = axis < 0 ? 1 : spans[index][axis] ?? 1
return span === 0 ? null : <td key={offset} rowSpan={span}
role={axis >= 0 ? 'rowheader' : undefined} data-counts-copy={value}
data-counts-aggregate={row.kind !== 'data' ? '' : undefined}
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>
})}
{columnHeaders.map (column => <td key={columnKey (column)}
data-counts-copy={String (count (row, column))}
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
data-counts-total={(row.kind === 'grand_total' || column.kind === 'grand_total')
? 'grand_total' : ((row.kind === 'subtotal' || column.kind === 'subtotal')
? 'subtotal' : undefined)}
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
? '' : undefined}
className="border px-2 py-1 dark:border-slate-700">
<PrefetchLink to={drill (row, column)}>{count (row, column)}</PrefetchLink>
</td>)}
</tr>)}</tbody>
</CountsSelectableTable>
</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
-24
ファイルの表示
@@ -1,24 +0,0 @@
import { useState } from 'react'
import { CountsLayoutContext, defaultCountWidths, MAX_COUNT_WIDTH, MIN_COUNT_WIDTH }
from '@/lib/countsTableLayout'
import type { ReactNode } from 'react'
const CountsTableLayout = ({ children }: { children: ReactNode }) => {
const [widths, setWidths] = useState<Record<string, number>> ({ })
const width = (family: string, key: string) =>
widths[`${family}/${key}`] ?? defaultCountWidths[key] ?? 160
const resize = (family: string, key: string, value: number) => {
const next = Math.round (Math.max (MIN_COUNT_WIDTH, Math.min (MAX_COUNT_WIDTH, value)))
setWidths (previous => ({ ...previous, [`${family}/${key}`]: next }))
}
return (
<CountsLayoutContext.Provider value={{ width, resize }}>
{children}
</CountsLayoutContext.Provider>)
}
export default CountsTableLayout
-70
ファイルの表示
@@ -1,70 +0,0 @@
.counts-grid > tbody > tr > td {
@apply h-6;
}
.counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
@apply bg-white text-slate-900;
}
.counts-grid > tbody > tr:nth-child(even) > td:not([data-counts-selected]) {
@apply bg-slate-50 text-slate-900;
}
.dark .counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
@apply bg-slate-950 text-slate-100;
}
.dark .counts-grid > tbody > tr:nth-child(even) > td:not([data-counts-selected]) {
@apply bg-slate-900 text-slate-100;
}
.counts-grid > thead {
@apply bg-slate-200 text-slate-950;
}
.counts-grid > thead > tr > th {
@apply border-b border-r border-slate-300 bg-slate-200 text-slate-950;
}
.dark .counts-grid > thead,
.dark .counts-grid > thead > tr > th {
@apply border-slate-600 bg-slate-800 text-slate-50;
}
.counts-grid > tbody > tr > td {
@apply border-r border-slate-200 dark:border-slate-700;
-webkit-touch-callout: none;
}
.counts-grid > tbody > tr > td:has(> .counts-nested) {
padding: 0;
}
.counts-nested {
@apply border-l-2 border-slate-300 dark:border-slate-600;
}
/* Aggregate kind is semantic; only aggregate rows receive a background. */
.counts-grid > tbody > tr > td[data-counts-total],
.counts-grid > thead > tr > th[data-counts-total] {
@apply border-l border-l-slate-400 font-semibold dark:border-l-slate-500;
}
.counts-grid > tbody > tr > td[data-counts-row-total='subtotal']:not([data-counts-selected]) {
background-color: #e2e8f0;
color: #0f172a;
font-weight: 600;
}
.counts-grid > tbody > tr > td[data-counts-row-total='grand_total']:not([data-counts-selected]) {
background-color: #cbd5e1;
color: #0f172a;
font-weight: 700;
}
.dark .counts-grid > tbody > tr > td[data-counts-row-total='subtotal']:not([data-counts-selected]) {
background-color: #334155;
color: #f1f5f9;
}
.dark .counts-grid > tbody > tr > td[data-counts-row-total='grand_total']:not([data-counts-selected]) {
background-color: #475569;
color: #fff;
}
+10 -29
ファイルの表示
@@ -8,7 +8,6 @@ import { Dialog,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { DialogueContext } from '@/lib/dialogues/useDialogue'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, ReactNode } from 'react'
@@ -156,22 +155,6 @@ const DialogueProvider: FC<Props> = ({ children }) => {
},
[closeRequest, pendingIds])
const dismiss = async (request: DialogueRequest) => {
if (request.kind === 'form' && request.options.onCancel != null)
{
setPendingIds (current => [...current, request.id])
try
{
if (await request.options.onCancel () === false) return
}
finally
{
setPendingIds (current => current.filter (id => id !== request.id))
}
}
closeRequest (request.id, request.kind === 'confirm' ? false : null)
}
const active = queue[0]
const startActions =
active?.kind === 'form'
@@ -195,17 +178,18 @@ const DialogueProvider: FC<Props> = ({ children }) => {
nestedConfirm?.parentId === active.id
|| pendingIds.includes (active.id)
if (!(open) && !(blocked))
dismiss (active)
closeRequest (active.id, active.kind !== 'confirm' && null)
}}>
<DialogContent
closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
? active.options.closeLabel : undefined}
className={cn (active.kind === 'form'
? 'flex max-h-[calc(100dvh-1rem)] flex-col overflow-hidden gap-0'
: 'px-6 pb-6 pt-7', active.kind === 'form' && (
active.options.size === 'viewport'
? 'h-[88dvh] w-[88vw] max-w-none'
: (active.options.size === 'large' ? 'max-w-3xl' : 'max-w-lg')))}
className={
active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
? 'max-w-3xl'
: 'max-w-lg' } flex-col overflow-hidden gap-0`
: 'px-6 pb-6 pt-7'
}
onEscapeKeyDown={event => {
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
event.preventDefault ()
@@ -253,14 +237,11 @@ const DialogueProvider: FC<Props> = ({ children }) => {
</Button>))}
</div>
<div className={cn (
'flex w-full gap-2 md:w-auto md:flex-row',
active.options.footerLayout === 'inline'
? 'flex-row justify-end [&>button]:w-auto' : 'flex-col')}>
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
<Button
className="w-full md:w-auto"
variant="outline"
onClick={() => dismiss (active)}
onClick={() => closeRequest (active.id)}
disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id}>
{active.options.cancelText ?? label ('actions.cancel')}
+3 -4
ファイルの表示
@@ -8,11 +8,10 @@ import type { FC, ReactNode } from 'react'
type Props = {
children: ReactNode
className?: string
animateLayout?: boolean }
className?: string }
const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
const MainArea: FC<Props> = ({ children, className }) => {
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
@@ -24,7 +23,7 @@ const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
<motion.main
transition={{ layout: layoutTransition }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
layout={animationMode === 'off' ? false : 'position'}>
{children}
</motion.main>)
}
+6 -14
ファイルの表示
@@ -15,8 +15,6 @@ type Props = {
children: ReactNode
className?: string
maxWidth?: number
maxWidthRatio?: number
animateLayout?: boolean
onWidthChange?: (width: number) => void
sidebarKey: string
side?: SidebarSide }
@@ -69,8 +67,6 @@ const SidebarComponent: FC<Props> = ({
children,
className,
maxWidth,
maxWidthRatio,
animateLayout = true,
onWidthChange,
sidebarKey,
side = 'left',
@@ -84,7 +80,6 @@ const SidebarComponent: FC<Props> = ({
)
const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth)
const maxWidthRatioRef = useRef (maxWidthRatio)
const getContainerWidth = useCallback (() => {
const containerWidth = rootRef.current
?.closest ('[data-sidebar-container]')
@@ -98,14 +93,12 @@ const SidebarComponent: FC<Props> = ({
const viewportWidth =
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const ratioMaxWidth = maxWidthRatioRef.current == null
? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current)
const configuredMaxWidth = maxWidthRef.current == null
const maxAllowedWidth =
maxWidthRef.current == null
? getSidebarMaxWidth (containerWidth)
: Math.max (MIN_SIDEBAR_WIDTH,
: Math.max (
MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current))
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
Math.min (ratioMaxWidth, configuredMaxWidth))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
@@ -178,11 +171,10 @@ const SidebarComponent: FC<Props> = ({
useEffect (() => {
maxWidthRef.current = maxWidth
maxWidthRatioRef.current = maxWidthRatio
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio])
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth])
const updateWidth = useCallback ((clientX: number) => {
const state = resizeRef.current
@@ -318,7 +310,7 @@ const SidebarComponent: FC<Props> = ({
return (
<motion.div
ref={rootRef}
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: layoutTransition }}
style={style}
className={cn (
+2 -6
ファイルの表示
@@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import { useClientMotion } from '@/lib/useClientMotion'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
@@ -38,17 +37,14 @@ const DialogContent = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
& { closeLabel?: React.ReactNode }
>(
({ className, children, closeLabel, style, ...props }, ref) => {
({ className, children, closeLabel, ...props }, ref) => {
const { i18n } = useTranslation ('shared')
const motion = useClientMotion ()
const animationDuration = `${motion.duration ('popover')}ms`
const close = translateUIMessage ('dialogue.close', 'shared', i18n.language)
return (
<DialogPortal>
<DialogOverlay style={{ animationDuration }}/>
<DialogOverlay/>
<DialogPrimitive.Content
ref={ref}
style={{ animationDuration, ...style }}
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
+1 -3
ファイルの表示
@@ -66,9 +66,7 @@ export const render = async (
authentication: Pick<RenderContext, 'cookie' | 'forwardedFor'> = { },
): Promise<RenderedPage | RouteResponse> => {
const url = new URL (address, origin)
const legacy = legacyLocalePath (
{ pathname: url.pathname, search: url.search, hash: url.hash },
pathname => resolvePageRoute (pathname) != null)
const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' })
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
const segment = pathname.split ('/')[1]
const known = resolvePageRoute (pathname)
-1
ファイルの表示
@@ -1,5 +1,4 @@
@import "@fontsource-variable/noto-sans-jp";
@import './components/counts/countsTable.css';
@tailwind base;
@tailwind components;
-37
ファイルの表示
@@ -199,40 +199,3 @@ export const apiDelete = async <T = void> (
export const isApiError = <T = unknown> (err: unknown): err is AxiosError<T> =>
axios.isAxiosError (err)
// The server acknowledges a complete file before native download starts.
export const apiDownload = async (
path: string,
body: Record<string, string>,
locale: LocaleCode,
onError?: () => void,
): Promise<void> => {
const { token } = await apiPost<{ token: string }> (`${path}/prepare`, body, { locale })
if (typeof token !== 'string' || token.length === 0)
throw new Error ('Missing download token')
startAPIDownload (path, token, onError)
}
export const startAPIDownload = (
path: string,
token: string,
onError?: () => void,
): void => {
const frame = document.createElement ('iframe')
frame.hidden = true
frame.addEventListener ('load', () => {
const document = frame.contentDocument
if (document != null && document.URL !== 'about:blank')
{
onError?.()
frame.remove ()
}
})
frame.addEventListener ('error', () => { onError?.(); frame.remove () })
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}`
document.body.append (frame)
// Attachments do not reliably fire load; keep this isolated from page navigation.
window.setTimeout (() => frame.remove (), 10 * 60_000)
}
-6
ファイルの表示
@@ -32,9 +32,3 @@ export const clientScrollBehaviour = (
animationMode: ClientAnimationMode,
): ScrollBehavior =>
animationMode === 'off' ? 'auto' : 'smooth'
export const clientMotionTokens = {
durationMS: { feedback: 120, popover: 160, collapse: 220, reorder: 200, drop: 180 },
easing: [.2, 0, 0, 1] as const,
cssEasing: 'cubic-bezier(.2, 0, 0, 1)',
distance: 4 }
-41
ファイルの表示
@@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest'
import { columnsForCounts, countColumns, countsCSV, normaliseCountsParams } from '@/lib/counts'
import { createUII18n, translateUIMessage } from '@/lib/i18n'
describe ('counts exports', () => {
it ('resolves every output and CSV header key in both locales', () => {
for (const locale of ['ja', 'ain-Kana'])
{
const instance = createUII18n (locale)
for (const key of Object.values (countColumns))
{
const translated = translateUIMessage (key, 'counts', locale, { }, instance)
expect (translated.text).not.toBe (key)
expect (translated.text).not.toContain ('counts:')
}
const fallback = translateUIMessage ('run', 'counts', locale, { }, instance)
expect (fallback).toEqual ({ text: '集計実行', language: 'ja' })
}
})
it ('makes the monthly default explicit for the existing API', () => {
expect (normaliseCountsParams ('posts', new URLSearchParams ()).get ('axis'))
.toBe ('month')
})
it ('escapes quotes, newlines and spreadsheet formulas in UTF-8 CSV', () => {
expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']]))
.toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"')
})
it ('keeps resource-specific columns out of the other resource', () => {
expect (columnsForCounts ('posts')).toContain ('added_tags')
expect (columnsForCounts ('posts')).not.toContain ('aliases')
expect (columnsForCounts ('tags')).toContain ('aliases')
expect (columnsForCounts ('tags')).not.toContain ('url')
expect (columnsForCounts ('posts')).not.toContain ('event_type')
expect (columnsForCounts ('tags')).not.toContain ('event_type')
})
})
-511
ファイルの表示
@@ -1,511 +0,0 @@
import { countsFieldLabel } from '@/lib/countsDefinitions'
import { apiDownload, apiGet } from '@/lib/api'
import { readCountState, encodeCountState } from '@/lib/queryState'
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types'
export type CountsResource = 'posts' | 'tags' | 'nico_tags'
| 'tag_descriptions' | 'tag_groups' | 'materials'
export type CountsName = TagNameSnapshot & {
languageCode: string; languageName?: string | null; scriptName?: string | null }
export type CountsTag = {
id: number
kind: 'internal' | 'external'
tagId: number | null
externalTagId: number | null
versionNo: number | null
name: string | null
category: string | null
sections: { beginMs: number; endMs: number | null }[]
names: CountsName[] }
export type CountsVersion = {
[key: string]: unknown
eventType?: string
id: number
resourceId: number
versionNo: number
isLatest: boolean
createdAt: string
createdByUser: { id: number; name: string | null } | null
title?: string | null
url?: string
thumbnailBase?: string | null
tags?: CountsTag[]
parents: { id: number }[]
videoMs?: number | null
originalCreatedFrom?: string | null
originalCreatedBefore?: string | null
name?: string | null
names?: CountsName[]
category?: string
aliases?: { languageCode: string; name: string; scriptCode: string }[]
deprecated?: string | null }
export type CountsStateRow = {
kind: 'internal' | 'external'
tagId: number
name: string | null
deprecated?: string | null
period?: string
at: string
count: number | null }
export type CountsStateTotals = {
posts: number | null
tags: number | null
assignments: number | null
deprecatedTags: number | null
externalAssignments: number | null }
export type CountsPivotHeader = { values: (string | number | null)[]
kind: 'data' | 'subtotal' | 'grand_total' }
export type CountsResult = {
populationCount?: number
excludedCount?: number
outputCount?: number | null
visibleOutputCount?: number
outputCountScope?: 'before_authorisation' | 'unavailable_private_history'
populationCountScope?: string
rowHeaders?: CountsPivotHeader[]
columnHeaders?: CountsPivotHeader[]
pivotTotals?: { row: (string | number | null)[]; column: (string | number | null)[]
count: number }[]
executionAt?: string
columnValues?: (string | number | null)[][]
cells?: { values: (string | number | null)[]; count: number }[]
axisLabels?: Record<string, Record<string, string | null>>
rows?: { field: string; grain?: string }[]
cols?: { field: string; grain?: string }[]
count: number
page: number
limit: number
groupCount?: number
groups?: { key: string | number | null; name: string | null; count: number }[]
versions?: CountsVersion[]
at?: string
from?: string
before?: string
grain?: string
totals?: CountsStateTotals
stateRows?: CountsStateRow[]
coverage?: { basis: string; exact: boolean
postHistoryFrom: string | null; tagHistoryFrom: string | null } }
export type CountSort = { key: string; direction: 'asc' | 'desc' }
export type CountField = { key: string; label: string; children?: CountField[] }
export type CountRecord = Record<string, unknown>
export const countColumns: Record<string, string> = {
event_type: 'counts:eventType',
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
version_no: 'counts:columns.version', is_latest: 'counts:columns.latest',
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
original_created_from: 'counts:columns.originalFrom',
original_created_before: 'counts:columns.originalBefore',
name: 'counts:columns.name', names: 'counts:columns.names',
category: 'common:categoryLabel', aliases: 'tags:aliases',
deprecated: 'counts:columns.deprecated', user: 'history:actor',
created_at: 'counts:columns.changedAt' }
export type CountColumn = string
const aliasFields: CountField[] = [
{ key: 'name', label: 'history:name' },
{ key: 'scriptCode', label: 'history:nameChanges.script' }]
const nameFields: CountField[] = [
{ key: 'languageCode', label: 'counts:columns.language' },
{ key: 'scriptCode', label: 'history:nameChanges.script' },
{ key: 'name', label: 'history:name' },
{ key: 'aliases', label: 'tags:aliases', children: aliasFields },
{ key: 'autoGenerated', label: 'history:nameChanges.automatic' }]
const placementFields = (depth: number): CountField[] => [
{ key: 'sectionName', label: 'counts:attributes.section_name' },
{ key: 'description', label: 'counts:attributes.description' },
{ key: 'tagId', label: 'counts:attributes.tag_id' },
{ key: 'tagGroupId', label: 'counts:resources.tag_groups' },
...(depth > 0 ? [{ key: 'children', label: 'counts:attributes.children',
children: placementFields (depth - 1) }] : [])]
const nested: Partial<Record<CountColumn, CountField[]>> = {
placements: placementFields (4),
export_paths_json: [
{ key: 'profile', label: 'counts:attributes.profile' },
{ key: 'path', label: 'counts:attributes.path' }],
members: [
{ key: 'tagId', label: 'counts:columns.resourceId' },
{ key: 'tagGroupId', label: 'counts:resources.tag_groups' },
{ key: 'versionNo', label: 'history:version' }],
tags: [
{ key: 'id', label: 'counts:columns.resourceId' },
{ key: 'versionNo', label: 'history:version' },
{ key: 'kind', label: 'counts:columns.kind' },
{ key: 'name', label: 'history:name' },
{ key: 'category', label: 'common:categoryLabel' },
{ key: 'sections', label: 'counts:columns.sections' },
{ key: 'names', label: 'counts:columns.names', children: nameFields }],
names: nameFields,
aliases: [{ key: 'languageCode', label: 'counts:columns.language' }, ...aliasFields],
parents: [{ key: 'id', label: 'counts:columns.resourceId' }] }
export const columnsForCounts = (
resource: CountsResource,
definition?: CountsDefinition,
): CountColumn[] => definition?.outputFields?.map (field => field.key) ?? [
...(resource === 'posts' ? ['thumbnail'] : []),
'resource_id', 'version_no', 'is_latest',
...(resource === 'posts'
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
'original_created_from', 'original_created_before']
: (resource === 'tags'
? ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
: (definition?.fields.filter (field => ![
'resource_id', 'version_no', 'event_type', 'user', 'created_at']
.includes (field.key)).map (field => field.key) ?? []))),
'event_type', 'user', 'created_at']
export const fieldsForCounts = (
resource: CountsResource,
definition?: CountsDefinition,
): CountField[] => definition?.outputFields
?? columnsForCounts (resource, definition).map (key => ({
key,
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
: (countColumns[key] ?? countsFieldLabel (key)),
children: key === 'parents' && resource === 'tags'
? [...(nested.parents ?? []), { key: 'name', label: 'counts:columns.name' }]
: nested[key] }))
export const orderedCountFields = (
fields: CountField[],
params: URLSearchParams,
parent = '',
): CountField[] => {
const prefix = parent ? `${parent}.` : ''
const saved = (params.get ('order') ?? '').split (' ')
const keys = [...new Set ([...saved, ...fields.map (field => `${prefix}${field.key}`)])]
const ordered = keys.flatMap (key => {
const field = fields.find (field => `${prefix}${field.key}` === key)
return field == null ? [] : [field]
})
return parent ? ordered : [
...ordered.filter (field => field.key === 'thumbnail'),
...ordered.filter (field => field.key !== 'thumbnail')]
}
export const reorderCountFields = (
fields: CountField[],
params: URLSearchParams,
parent: string,
from: string,
to: string,
): string => {
const ordered = orderedCountFields (fields, params, parent)
const first = ordered.findIndex (field => field.key === from)
const last = ordered.findIndex (field => field.key === to)
if (first < 0 || last < 0) return params.get ('order') ?? ''
const prefix = parent ? `${parent}.` : ''
const current = ordered.map (field => `${prefix}${field.key}`)
const next = [...current]
next.splice (last, 0, ...next.splice (first, 1))
return [...(params.get ('order') ?? '').split (' ').filter (
key => key && !current.includes (key)), ...next].join (' ')
}
export const selectedCountColumns = (
resource: CountsResource,
params: URLSearchParams,
definition?: CountsDefinition,
): CountColumn[] => {
const resolved = normaliseCountsParams (resource, params, definition)
const common = ['resource_id', 'version_no', 'event_type', 'user', 'created_at']
const defaults: Record<CountsResource, string[]> = {
posts: ['title', 'url', 'tags', 'parents', 'video_ms'],
tags: ['name', 'category', 'aliases', 'parents'],
nico_tags: ['name', 'linked_tags'],
tag_descriptions: ['language_code', 'body', 'source_language_code'],
tag_groups: ['name', 'description', 'members'],
materials: ['url', 'source_kind', 'tag_id'] }
const selected = resolved.has ('columns') ? resolved.get ('columns')!.split (' ')
: [...common, ...defaults[resource]]
return orderedCountFields (fieldsForCounts (resource, definition), resolved)
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
}
export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
fields.flatMap (field => {
const path = prefix ? `${prefix}.${field.key}` : field.key
return [path, ...countChildPaths (field.children ?? [], path)]
})
export const selectedCountChildren = (params: URLSearchParams): Set<string> => {
const selected = new Set (params.has ('children') ? params.get ('children')!.split (' ')
: ['tags.name', 'parents.id', 'aliases.name', 'members.tagId'])
const clean = (fields: CountField[], prefix: string) => {
for (const field of fields)
{
const path = `${prefix}.${field.key}`
if (field.children == null) continue
clean (field.children, path)
if (!field.children.some (child => selected.has (`${path}.${child.key}`)))
selected.delete (path)
}
}
for (const [path, fields] of Object.entries (nested)) clean (fields ?? [], path)
return selected
}
export const readCountSort = (value: string | null): CountSort[] =>
(value ?? '').split (' ').flatMap (entry => {
const [key, direction] = entry.split (':')
return (key && (direction === 'asc' || direction === 'desc'))
? [{ key, direction }] : []
})
export const writeCountSort = (sort: CountSort[]): string =>
sort.map (item => `${item.key}:${item.direction}`).join (' ')
export const changeCountSort = (
sort: CountSort[],
key: string,
additive: boolean,
): CountSort[] => {
const previous = sort.find (item => item.key === key)
if (!additive)
return [{ key, direction: previous?.direction === 'asc' ? 'desc' : 'asc' }]
if (previous == null)
return [...sort, { key, direction: 'asc' }]
return previous.direction === 'desc'
? sort.filter (item => item.key !== key)
: sort.map (item => item.key === key ? { key, direction: 'desc' } : item)
}
export const selectedCountFields = (
fields: CountField[],
params: URLSearchParams,
selectionKey: string,
parent = '',
): CountField[] => {
const selected = params.has (selectionKey)
? new Set (params.get (selectionKey)!.split (' ')) : null
return orderedCountFields (fields, params, parent)
.filter (field => selected == null || selected.has (field.key))
}
export const defaultCountsMode = (definition: string | null): 'summary' | 'details' =>
definition === 'history' ? 'details' : 'summary'
export const countsDefinitionParams = (
definition: 'latest' | 'history',
previous: URLSearchParams,
): URLSearchParams => {
const next = new URLSearchParams ({ definition, mode: defaultCountsMode (definition) })
for (const key of ['timezone', 'row_numbers'])
if (previous.has (key)) next.set (key, previous.get (key)!)
return next
}
export const normaliseCountsParams = (
resource: CountsResource,
source: URLSearchParams,
definition?: CountsDefinition,
): URLSearchParams => {
const params = new URLSearchParams (source)
if (!params.has ('definition'))
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
if (params.get ('definition') === 'latest' && !params.has ('current'))
params.set ('current', params.get ('at') ? '0' : '1')
const legacy = params.get ('mode')
if (['state', 'trend', 'snapshot'].includes (legacy ?? ''))
params.set ('mode', legacy === 'snapshot' ? 'details' : 'summary')
if (!params.has ('mode')) params.set ('mode', defaultCountsMode (params.get ('definition')))
if (!params.has ('rows') && params.has ('axis'))
{
const field = params.get ('axis') === 'month' ? 'created_at' : params.get ('axis')!
params.set ('rows', encodeCountState ('rows', [{
field, ...(field === 'created_at' ? { grain: 'month' } : { }) }]))
}
if (params.has ('before') && !params.has ('through'))
params.set ('through', params.get ('before')!)
for (const key of ['basis', 'axis', 'latest', 'grain', 'before', 'summary_columns'])
params.delete (key)
if (params.has ('columns') && ['posts', 'tags'].includes (resource))
{
const selected = new Set (params.get ('columns')!.split (' '))
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
.join (' '))
}
if (params.has ('sort'))
params.set ('sort', params.get ('sort')!.split (' ')
.filter (entry => entry.split (':')[0] !== 'original_created_at').join (' '))
if (params.has ('order'))
params.set ('order', params.get ('order')!.split (' ')
.filter (key => key !== 'original_created_at').join (' '))
const defaults = definition?.defaults?.find (entry =>
entry.definition === params.get ('definition') && entry.mode === params.get ('mode'))
for (const { key, value } of defaults?.params ?? [])
{
if (params.has (key)) continue
if (key === 'sort')
{
const expected = defaults?.params.find (entry => entry.key === 'rows')?.value
const axes = readCountState<CountsAxis[]> (params, 'rows', [])
const first = expected == null ? null : readCountState<CountsAxis[]> (new URLSearchParams ({ rows: expected }), 'rows', [])[0]
if (!Array.isArray (axes) || axes[0]?.field !== first?.field
|| (axes[0]?.grain ?? 'month') !== (first?.grain ?? 'month')) continue
}
params.set (key, value)
}
const children = selectedCountChildren (params)
if (params.has ('children')) params.set ('children', [...children].filter (Boolean).join (' '))
if (params.has ('columns'))
{
const fields = fieldsForCounts (resource, definition)
params.set ('columns', params.get ('columns')!.split (' ').filter (key => {
const field = fields.find (field => field.key === key)
return field != null && (field.children == null
|| field.children.some (child => children.has (`${key}.${child.key}`)))
}).join (' '))
}
return params
}
export const isCountsState = (params: URLSearchParams): boolean =>
params.get ('definition') !== 'history'
export const countsUseLatest = (params: URLSearchParams): boolean =>
params.get ('current') !== '0'
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
const result = new URLSearchParams (params)
for (const key of ['columns', 'summary_columns', 'children', 'child_sort',
'nested_json', 'order', 'basis', 'axis', 'latest', 'grain',
'current', 'row_numbers'])
result.delete (key)
if (isCountsState (params) && countsUseLatest (params)) result.delete ('at')
result.sort ()
return result
}
export const fetchCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
signal?: AbortSignal,
): Promise<CountsResult> => apiGet ('/counts/query', {
locale, signal, params: { ...Object.fromEntries (countsRequestParams (params)), resource } })
export const exportCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
format: 'json' | 'csv',
columns: string[],
headers: string[],
labels: Record<string, string>,
onError?: () => void,
): Promise<void> => apiDownload ('/counts/export', countsExportPayload (
resource, params, format, columns, headers, labels), locale, onError)
export const countsExportPayload = (
resource: CountsResource,
params: URLSearchParams,
format: 'json' | 'csv',
columns: string[],
headers: string[],
labels: Record<string, string>,
definition?: CountsDefinition,
): Record<string, string> => {
const payload = new URLSearchParams (params)
if (!payload.has ('children'))
{
const selected = selectedCountChildren (params)
payload.set ('children', countChildPaths (fieldsForCounts (resource, definition))
.filter (path => selected.has (path)).join (' '))
}
payload.delete ('current')
payload.delete ('row_numbers')
if (isCountsState (params) && countsUseLatest (params)) payload.delete ('at')
return { ...Object.fromEntries (payload), resource, format,
columns: columns.join (' '), headers: JSON.stringify (headers),
labels: JSON.stringify (labels) }
}
const stableValue = (value: unknown): unknown => {
if (Array.isArray (value)) return value.map (stableValue)
if (value != null && typeof value === 'object')
{
return Object.fromEntries (Object.entries (value)
.sort (([a], [b]) => a < b ? -1 : (a > b ? 1 : 0))
.map (([key, entry]) => [key, stableValue (entry)]))
}
return value
}
const compareValue = (first: unknown, second: unknown): number => {
if (Array.isArray (first) && Array.isArray (second) && first.length !== second.length)
return first.length - second.length
if (typeof first === 'number' && typeof second === 'number') return first - second
if (typeof first === 'boolean' && typeof second === 'boolean')
return Number (first) - Number (second)
const left = typeof first === 'object' ? JSON.stringify (stableValue (first)) : String (first)
const right = typeof second === 'object'
? JSON.stringify (stableValue (second)) : String (second)
return left < right ? -1 : (left > right ? 1 : 0)
}
export const sortedCountChildren = (
rows: CountRecord[],
path: string,
params: URLSearchParams,
valueFor: (row: CountRecord, key: string) => unknown = (row, key) => row[key],
): CountRecord[] => {
const sort = readCountSort (params.get ('child_sort'))
.filter (item => item.key.startsWith (`${path}/`))
return rows.map ((row, index) => ({ row, index })).sort ((first, second) => {
for (const item of sort)
{
const key = item.key.slice (path.length + 1)
const left = valueFor (first.row, key)
const right = valueFor (second.row, key)
if (left == null && right != null) return 1
if (left != null && right == null) return -1
if (left == null && right == null) continue
const compared = compareValue (left, right)
if (compared !== 0) return item.direction === 'asc' ? compared : -compared
}
return first.index - second.index
}).map (item => item.row)
}
export const countsCSV = (rows: string[][]): string =>
'\uFEFF' + rows.map (row => row.map (value => {
const safe = /^[=+@\-\t\r]/.test (value) ? `'${value}` : value
return `"${safe.replaceAll ('"', '""')}"`
}).join (',')).join ('\r\n')
-127
ファイルの表示
@@ -1,127 +0,0 @@
export type CountsClipboard = { text: string; html: string }
export type CountsClipboardColumn = { label: string; children?: CountsClipboardColumn[] }
export type CountsClipboardCell = string | CountsClipboardTable
export type CountsClipboardTable = {
columns: CountsClipboardColumn[]; rows: CountsClipboardCell[][]; headerRows?: string[][] }
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!)
const columnWidth = (column: CountsClipboardColumn): number =>
column.children?.reduce ((sum, child) => sum + columnWidth (child), 0) || 1
const headerDepth = (columns: CountsClipboardColumn[]): number =>
columns.length === 0 ? 0 : 1 + Math.max (0, ...columns.map (column =>
headerDepth (column.children ?? [])))
const headerRows = (columns: CountsClipboardColumn[]): string[][] => {
const width = columns.reduce ((sum, column) => sum + columnWidth (column), 0)
const rows = Array.from ({ length: headerDepth (columns) },
() => Array<string> (width).fill (''))
const place = (fields: CountsClipboardColumn[], row: number, offset: number) => {
for (const field of fields)
{
rows[row][offset] = field.label
if (field.children?.length) place (field.children, row + 1, offset)
offset += columnWidth (field)
}
}
if (columns.length) place (columns, 0, 0)
return rows
}
const dataRows = (
table: CountsClipboardTable,
columns = table.columns,
): string[][] => {
if (columns.length === 0) return []
return table.rows.flatMap (record => {
const blocks = columns.map ((column, index) => {
const value = record[index] ?? ''
return typeof value === 'string' ? [[value]]
: dataRows (value, column.children ?? value.columns)
})
const height = Math.max (1, ...blocks.map (block => block.length))
// Sibling arrays share the parent row's height; they never form a Cartesian product.
return Array.from ({ length: height }, (_, row) => columns.flatMap ((column, index) =>
Array.from ({ length: columnWidth (column) }, (_, offset) =>
blocks[index][row]?.[offset] ?? '')))
})
}
export const countsClipboard = (
source: string[][] | CountsClipboardTable,
headers = false,
): CountsClipboard => {
const headings = (!Array.isArray (source) && headers)
? source.headerRows ?? headerRows (source.columns) : []
const rows = Array.isArray (source) ? source : [...headings, ...dataRows (source)]
const headingCount = Array.isArray (source) ? (headers ? 1 : 0) : headings.length
// Excel accepts quoted TSV fields only when delimiters or quotes require them.
// Prefix formula-like text in both representations, including leading whitespace.
const safe = rows.map (row => row.map (value => /^\s*[=+@-]/.test (value)
? `'${value}` : value))
const text = safe.map (row => row.map (value => /[\t\r\n"]/.test (value)
? `"${value.replaceAll ('"', '""')}"` : value).join ('\t')).join ('\r\n')
const html = '<html><body><table>' + safe.map ((row, index) => '<tr>' + row.map (value => {
const tag = index < headingCount ? 'th' : 'td'
// Preserve time strings, leading zeroes, and user text instead of inferring dates/formulae.
const numeric = value.length <= 15 && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test (value)
const style = numeric ? 'white-space:pre-wrap' : 'white-space:pre-wrap;mso-number-format:"\\@"'
return `<${tag} style="${escapeHTML (style)}">${escapeHTML (value)
.replace (/\r\n|\r|\n/g, '<br>')}</${tag}>`
}).join ('') + '</tr>').join ('') + '</table></body></html>'
return { text, html }
}
export const writeCountsClipboard = async (payload: CountsClipboard): Promise<void> => {
if (navigator.clipboard?.write != null && typeof ClipboardItem !== 'undefined')
{
try
{
await navigator.clipboard.write ([new ClipboardItem ({
'text/plain': new Blob ([payload.text], { type: 'text/plain' }),
'text/html': new Blob ([payload.html], { type: 'text/html' }) })])
return
}
catch
{
// Browsers without HTML clipboard support may still permit plain TSV.
}
}
if (navigator.clipboard?.writeText != null)
{
try
{
await navigator.clipboard.writeText (payload.text)
return
}
catch
{
// Try the synchronous copy event when clipboard permission is unavailable.
}
}
// The synchronous copy event fallback is also used on non-secure local origins.
const listener = (event: ClipboardEvent) => {
event.preventDefault ()
event.stopImmediatePropagation ()
event.clipboardData?.setData ('text/plain', payload.text)
event.clipboardData?.setData ('text/html', payload.html)
}
document.addEventListener ('copy', listener, true)
try
{
if (!document.execCommand ('copy')) throw new Error ('Clipboard unavailable')
}
finally
{
document.removeEventListener ('copy', listener, true)
}
}
-81
ファイルの表示
@@ -1,81 +0,0 @@
import { createContext, useContext } from 'react'
import { translateUIMessage } from '@/lib/i18n'
import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { CountField, CountsResource } from '@/lib/counts'
export type CountsAxis = { field: string; grain?: string }
export type CountsCondition = {
field: string; operator: string; value?: string; end?: string; blank?: boolean }
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
| 'json' | 'boolean' | 'interval'
export const countsResources: CountsResource[] = [
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
export type CountsFieldMetadata = {
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
labels?: string[]; multiple?: boolean }
export type CountsDefinition = {
fields: CountsFieldMetadata[]
outputFields?: CountField[]
filterFields?: CountsFieldMetadata[]
defaults?: { definition: string; mode: string; params: { key: string; value: string }[] }[]
axes: { latest: string[]; history: string[] }
grains: string[] }
export const CountsDefinitionContext = createContext<CountsDefinition | null> (null)
export const useCountsDefinition = (): CountsDefinition => {
const definition = useContext (CountsDefinitionContext)
if (definition == null) throw new Error ('Missing Counts definition')
return definition
}
export const fetchCountsDefinition = (resource: CountsResource): Promise<CountsDefinition> =>
apiGet (`/counts/definitions/${encodeURIComponent (resource)}`)
export const countsFilterFields = (
definition: CountsDefinition,
): Record<string, CountsFieldType> => Object.fromEntries (
(definition.filterFields ?? definition.fields).map (field => [field.key, field.type]))
export const countsFieldLabel = (key: string): string => {
const labels: Record<string, string> = {
resource_id: 'columns.resourceId', version_no: 'columns.version',
user: 'history:actor', created_at: 'columns.changedAt', name: 'history:name',
title: 'columns.postTitle', url: 'columns.postURL', thumbnail_base: 'columns.thumbnailURL',
tags: 'columns.tags', tags_json: 'columns.tags', names: 'columns.names',
aliases: 'tags:aliases', category: 'common:categoryLabel',
language_code: 'columns.language', source_language_code: 'attributes.sourceLanguage',
changed_field: 'changedField' }
return labels[key] ?? `attributes.${key}`
}
export const countsEventTypes = ['create', 'update', 'discard', 'restore'] as const
export const countsAxisLabel = (
axis: CountsAxis,
definition: CountsDefinition,
locale: LocaleCode,
): { text: string; language: string } => {
const field = translateUIMessage (countsFieldLabel (axis.field), 'counts', locale)
if (definition.fields.find (item => item.key === axis.field)?.type !== 'datetime') return field
return translateUIMessage ('axisWithGrain', 'counts', locale, {
field: field.text,
grain: translateUIMessage (`grains.${axis.grain ?? 'month'}`, 'counts', locale).text })
}
export const countsAxisFields = (
definition: CountsDefinition,
history: boolean,
): CountField[] => definition.axes[history ? 'history' : 'latest']
.map (key => ({ key, label: countsFieldLabel (key) }))
-411
ファイルの表示
@@ -1,411 +0,0 @@
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
sortedCountChildren } from '@/lib/counts'
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
import { translateUIMessage } from '@/lib/i18n'
import { localisedDateString, msToTime } from '@/lib/utils'
import type { CountColumn, CountField, CountRecord, CountsResult,
CountsResource, CountsVersion } from '@/lib/counts'
import type { CountsClipboardCell, CountsClipboardColumn, CountsClipboardTable }
from '@/lib/countsClipboard'
import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
import type { LocaleCode } from '@/lib/locales'
export const countsCellText = (
row: CountsVersion,
column: CountColumn,
locale: LocaleCode,
params = new URLSearchParams (),
): string => {
const timezone = params.get ('timezone') ?? 'UTC'
const date = (value: string) => localisedDateString (value, locale, null, timezone)
switch (column)
{
case 'resource_id': return String (row.resourceId)
case 'version_no': return String (row.versionNo)
case 'is_latest':
return row.isLatest ? translateUIMessage ('latestValue', 'counts', locale).text : ''
case 'title': return row.title ?? ''
case 'url': return row.url ?? ''
case 'thumbnail':
case 'thumbnail_base': return row.thumbnailBase ?? ''
case 'name': return row.name ?? ''
case 'category':
return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text
case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs)
case 'original_created_from':
case 'original_created_before':
{
const value = column === 'original_created_from'
? row.originalCreatedFrom : row.originalCreatedBefore
return value == null ? '' : date (value)
}
case 'deprecated':
return row.deprecated == null ? '' : date (row.deprecated)
case 'user':
if (row.createdByUser == null)
return translateUIMessage ('bot', 'counts', locale).text
return row.createdByUser.name
? translateUIMessage ('actorWithId', 'counts', locale, {
name: row.createdByUser.name, id: row.createdByUser.id }).text
: translateUIMessage ('history:anonymous', 'counts', locale, {
id: row.createdByUser.id }).text
case 'created_at': return date (row.createdAt)
default:
{
const key = column.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())
const value = row[key]
if (['discarded_at', 'updated_at'].includes (column) && typeof value === 'string')
return date (value)
if (column === 'event_type')
return translateUIMessage (`events.${value}`, 'counts', locale).text
if (column === 'language_code' || column === 'source_language_code')
return countsLogicalName ({ languageCode: value }, 'languageCode', locale).text
return value == null ? '' : (typeof value === 'object'
? JSON.stringify (value, null, 2) : String (value))
}
}
}
export const countsNestedRows = (row: CountsVersion, column: string): CountRecord[] => {
const key = column.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())
const value = row[key]
if (column === 'export_paths_json' && value != null && typeof value === 'object')
return Object.entries (value).map (([profile, path]) => ({ profile, path }))
return Array.isArray (value) ? value as CountRecord[] : []
}
export const countsCSVCell = (
row: CountsVersion,
column: CountColumn,
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
): string => {
const field = fieldsForCounts (resource).find (field => field.key === column)!
if (field.children != null)
{
const rows = countsNestedRows (row, column)
return JSON.stringify (countsNestedExport (rows, field.children, column, params, locale))
}
return countsCellText (row, column, locale, params)
}
const countsSectionRanges = (value: unknown, locale: LocaleCode): string[] | null => {
if (!Array.isArray (value)) return null
const time = (ms: number): string => {
const parts = msToTime (ms).split (':')
if (parts.length === 2) parts[0] = parts[0].padStart (2, '0')
return parts.join (':')
}
return value.map ((section: { beginMs: number; endMs: number | null }) =>
translateUIMessage ('sections.range', 'counts', locale, {
begin: time (section.beginMs),
end: section.endMs == null ? '' : time (section.endMs) }).text)
}
const displayNames = new Map<string, Intl.DisplayNames> ()
export const countsLogicalName = (
row: CountRecord,
field: string,
locale: LocaleCode,
): { text: string; language?: string } => {
const value = row[field]
if (value == null) return { text: '' }
if (field === 'sections')
return { text: (countsSectionRanges (value, locale) ?? []).join (
translateUIMessage ('sections.separator', 'counts', locale).text) }
const code = String (value)
if (field === 'languageCode' || field === 'scriptCode')
{
if (field === 'languageCode' && ['ja', 'ain'].includes (code))
return translateUIMessage (`history:languageNames.${code}`, 'counts', locale)
const master = row[field === 'languageCode' ? 'languageName' : 'scriptName']
if (typeof master === 'string' && master.trim () !== ''
&& master.toLowerCase () !== code.toLowerCase ())
return { text: master }
try
{
const type = field === 'languageCode' ? 'language' : 'script'
if (!displayNames.has (type))
displayNames.set (type, new Intl.DisplayNames (['ja'], { type, fallback: 'none' }))
const text = displayNames.get (type)!.of (code)
if (text != null && text !== code) return { text, language: 'ja' }
}
catch
{
// Invalid or unregistered codes have no logical label; never expose the code as a name.
}
return translateUIMessage ('notRegistered', 'counts', locale)
}
if (field === 'kind' || field === 'category')
return translateUIMessage (field === 'kind' ? `kinds.${code}`
: `common:categories.${code}`, 'counts', locale)
if (typeof value === 'boolean')
return translateUIMessage (`boolean.${value}`, 'counts', locale)
return { text: String (value) }
}
export const countsNestedOrder = (
rows: CountRecord[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): CountRecord[] => sortedCountChildren (rows, path, params, (row, key) =>
['languageCode', 'scriptCode'].includes (key)
? countsLogicalName (row, key, locale).text : row[key])
export const countsNestedExport = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): unknown => {
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
if (columns.length === 0) return rows.map (() => ({ }))
return countsNestedOrder (rows, path, params, locale).map (row =>
Object.fromEntries (columns.map (field => [
translateUIMessage (field.label, 'counts', locale).text,
field.children
? countsNestedExport (row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)
: (field.key === 'sections'
? countsSectionRanges (row[field.key], locale)
: countsLogicalName (row, field.key, locale).text)])))
}
// A chain with one selected scalar leaf is displayed and copied as one cell.
export const countsNestedIsScalar = (
fields: CountField[],
path: string,
params: URLSearchParams,
): boolean => {
const selected = selectedCountChildren (params)
const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
if (columns.length !== 1) return false
const field = columns[0]
return field.children == null
|| countsNestedIsScalar (field.children, `${path}.${field.key}`, params)
}
export const countsNestedScalarText = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): string => {
const selected = selectedCountChildren (params)
const field = fields.find (field => selected.has (`${path}.${field.key}`))
if (field == null) return ''
return countsNestedOrder (rows, path, params, locale).map (row => field.children == null
? countsLogicalName (row, field.key, locale).text
: countsNestedScalarText (row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)).join (' ')
}
export const countsNestedText = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): string => {
if (countsNestedIsScalar (fields, path, params))
return countsNestedScalarText (rows, fields, path, params, locale)
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
if (columns.length === 0)
return `${translateUIMessage ('common:countLabel', 'counts', locale).text}: ${rows.length}`
return countsNestedOrder (rows, path, params, locale).map (row => columns.map (field => {
const value = field.children
? countsNestedText (row[field.key] as CountRecord[] ?? [], field.children,
`${path}.${field.key}`, params, locale)
: countsLogicalName (row, field.key, locale).text
return `${translateUIMessage (field.label, 'counts', locale).text}: ${value}`
}).join (' | ')).join ('\n')
}
export const countsCellCopy = (
row: CountsVersion,
field: CountField,
params: URLSearchParams,
locale: LocaleCode,
): string => {
if (field.children == null) return countsCellText (row, field.key as CountColumn, locale, params)
const rows = countsNestedRows (row, field.key)
if (countsNestedIsScalar (field.children, field.key, params))
return countsNestedScalarText (rows, field.children, field.key, params, locale)
return params.get ('nested_json') === '1'
? JSON.stringify (
countsNestedExport (rows, field.children, field.key, params, locale))
: countsNestedText (rows, field.children, field.key, params, locale)
}
// Historical URLs are displayed without fetching current attachments or metadata.
export const countsImageURL = (value: string | null | undefined): string | null => {
if (!value) return null
try
{
const url = new URL (value)
return (['https:', 'http:'].includes (url.protocol) && !url.username && !url.password)
? url.href : null
}
catch
{
return null
}
}
export const countsExportLabels = (
resource: CountsResource,
locale: LocaleCode,
definition?: CountsDefinition,
): Record<string, string> => {
const aliases: Record<string, string> = { versionNo: 'version_no',
languageCode: 'language', scriptCode: 'script', autoGenerated: 'auto_generated' }
const labels: Record<string, string> = { }
const visit = (fields: CountField[]) => {
for (const field of fields)
{
labels[aliases[field.key] ?? field.key] = translateUIMessage (
field.label, 'counts', locale).text
if (field.children) visit (field.children)
}
}
visit (fieldsForCounts (resource, definition))
for (const key of ['internal', 'external'])
labels[`kind:${key}`] = translateUIMessage (`kinds.${key}`, 'counts', locale).text
for (const key of ['deerjikist', 'meme', 'character', 'general', 'material', 'meta'])
labels[`category:${key}`] = translateUIMessage (
`common:categories.${key}`, 'counts', locale).text
for (const key of ['true', 'false'])
labels[`boolean:${key}`] = translateUIMessage (`boolean.${key}`, 'counts', locale).text
for (const key of ['ja', 'ain'])
labels[`language:${key}`] = translateUIMessage (
`history:languageNames.${key}`, 'counts', locale).text
labels.id = translateUIMessage ('columns.resourceId', 'counts', locale).text
labels.name = translateUIMessage ('history:name', 'counts', locale).text
for (const event of countsEventTypes)
labels[`event_type:${event}`] = translateUIMessage (`events.${event}`, 'counts', locale).text
labels.subtotal = translateUIMessage ('subtotal', 'counts', locale).text
labels.singleTotal = translateUIMessage ('singleTotal', 'counts', locale).text
labels.grandTotal = translateUIMessage ('grandTotal', 'counts', locale).text
labels.bot = translateUIMessage ('bot', 'counts', locale).text
labels.actorFormat = translateUIMessage ('actorWithId', 'counts', locale, {
name: '{{name}}', id: '{{id}}' }).text
labels.latest = translateUIMessage ('latestValue', 'counts', locale).text
labels.notRegistered = translateUIMessage ('notRegistered', 'counts', locale).text
return labels
}
const clipboardColumns = (
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): CountsClipboardColumn[] => {
const selected = selectedCountChildren (params)
return orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
.map (field => ({ label: translateUIMessage (field.label, 'counts', locale).text,
children: (field.children == null
|| countsNestedIsScalar (field.children, `${path}.${field.key}`, params))
? undefined : clipboardColumns (
field.children, `${path}.${field.key}`, params, locale) }))
}
export const countsNestedClipboardTable = (
rows: CountRecord[],
fields: CountField[],
path: string,
params: URLSearchParams,
locale: LocaleCode,
): CountsClipboardTable => {
const selected = selectedCountChildren (params)
const columns = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
return {
columns: clipboardColumns (fields, path, params, locale),
rows: countsNestedOrder (rows, path, params, locale).map (row => columns.map (field =>
field.children == null ? countsLogicalName (row, field.key, locale).text
: (countsNestedIsScalar (field.children, `${path}.${field.key}`, params)
? countsNestedScalarText (row[field.key] as CountRecord[] ?? [],
field.children, `${path}.${field.key}`, params, locale)
: countsNestedClipboardTable (row[field.key] as CountRecord[] ?? [],
field.children, `${path}.${field.key}`, params, locale)))) }
}
export const countsDetailClipboardTable = (
rows: CountsVersion[],
fields: CountField[],
params: URLSearchParams,
locale: LocaleCode,
rowStart: number | null = null,
): CountsClipboardTable => {
const columns = fields.map (field => ({
label: translateUIMessage (field.label, 'counts', locale).text,
children: (field.children == null
|| countsNestedIsScalar (field.children, field.key, params))
? undefined : clipboardColumns (
field.children, field.key, params, locale) }))
if (rowStart != null)
columns.unshift ({ label: translateUIMessage ('rowNumberHeading', 'counts', locale).text,
children: undefined })
return { columns, rows: rows.map ((row, index) => {
const cells: CountsClipboardCell[] = fields.map (field => field.children == null
? countsCellText (row, field.key, locale, params)
: (countsNestedIsScalar (field.children, field.key, params)
? countsNestedScalarText (countsNestedRows (row, field.key), field.children,
field.key, params, locale)
: countsNestedClipboardTable (countsNestedRows (row, field.key), field.children,
field.key, params, locale)))
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)
}
-94
ファイルの表示
@@ -1,94 +0,0 @@
import { isCountsState, selectedCountFields } from '@/lib/counts'
import { translateUIMessage } from '@/lib/i18n'
import type { CountField, CountsResource, CountsResult } from '@/lib/counts'
import type { LocaleCode } from '@/lib/locales'
export type CountsSummaryRow = {
key: string | number | null
name: string | null
count: number | null
kind?: 'internal' | 'external'
tagId?: number
deprecated?: string | null
at?: string }
export const countsSummaryFields = (
resource: CountsResource,
params: URLSearchParams,
): CountField[] => (isCountsState (params) && params.get ('axis') === 'tags')
? [{ key: 'kind', label: 'counts:columns.kind' },
{ key: 'tag_id', label: 'counts:columns.resourceId' },
{ key: 'name', label: 'history:tagName' },
{ key: 'deprecated', label: 'counts:columns.deprecated' },
{ key: 'count', label: 'counts:state.postCount' }]
: [{ key: 'group', label: `counts:axes.${params.get ('axis') ?? 'month'}` },
{ key: 'count', label: isCountsState (params)
? `counts:state.totals.${resource}` : 'counts:metric' }]
export const selectedCountsSummaryFields = (
resource: CountsResource,
params: URLSearchParams,
): CountField[] => selectedCountFields (
countsSummaryFields (resource, params), params, 'summary_columns', 'summary')
export const countsSummaryRows = (data: CountsResult): CountsSummaryRow[] =>
data.stateRows != null
? data.stateRows.map (row => ({ ...row, key: `${row.kind}:${row.tagId}` }))
: data.groups ?? []
export const countsSummaryCell = (
row: CountsSummaryRow,
key: string,
locale: LocaleCode,
): { text: string; language?: string } => {
switch (key)
{
case 'group': return row.key == null
? translateUIMessage ('bot', 'counts', locale)
: { text: row.name ?? String (row.key) }
case 'count': return { text: row.count == null ? '—' : String (row.count) }
case 'tag_id': return { text: String (row.tagId) }
case 'kind': return translateUIMessage (`kinds.${row.kind}`, 'counts', locale)
case 'name': return row.name == null
? translateUIMessage ('notRegistered', 'counts', locale) : { text: row.name }
case 'deprecated': return row.deprecated
? translateUIMessage ('common:states.deprecated', 'counts', locale) : { text: '' }
default: return { text: '' }
}
}
export const countsSummaryDrill = (
resource: CountsResource,
params: URLSearchParams,
row: CountsSummaryRow,
): string => {
const next = new URLSearchParams (params)
for (const key of ['sort', 'page']) next.delete (key)
next.set ('mode', 'details')
if (row.at) next.set ('at', row.at)
if (row.tagId != null)
{
next.set ('kind', row.kind!)
next.set ('tag', String (row.tagId))
}
else if (params.get ('axis') === 'user') next.set ('user_id', String (row.key ?? 'none'))
else
{
const first = `${row.key}-01T00:00:00Z`
const date = new Date (first)
date.setUTCMonth (date.getUTCMonth () + 1)
const last = date.toISOString ()
const from = next.get ('from')
const before = next.get ('before')
next.set ('from', (from != null && Date.parse (from) > Date.parse (first)) ? from : first)
next.set ('before',
(before != null && Date.parse (before) < Date.parse (last)) ? before : last)
}
return `/counts/${resource}?${next}`
}
-28
ファイルの表示
@@ -1,28 +0,0 @@
import { createContext, useContext } from 'react'
export const MIN_COUNT_WIDTH = 64
export const MAX_COUNT_WIDTH = 2000
export const defaultCountWidths: Record<string, number> = {
row_number: 72, thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
video_ms: 120, original_created_from: 240,
original_created_before: 240, name: 240, names: 480, category: 180,
aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160,
period: 200, tag_id: 88, id: 88, versionNo: 88, kind: 140, sections: 260,
languageCode: 180, scriptCode: 200, autoGenerated: 120 }
type Layout = {
width: (family: string, key: string) => number
resize: (family: string, key: string, width: number) => void }
export const CountsLayoutContext = createContext<Layout | null> (null)
export const CountsTableScope = createContext ('')
export const useCountsTableLayout = (): Layout => {
const context = useContext (CountsLayoutContext)
if (context == null) throw new Error ('Counts table layout provider is missing')
return context
}
+1 -3
ファイルの表示
@@ -41,9 +41,7 @@ type DialogueFormOptions = { title: ReactNode
description?: ReactNode
body: (controls: DialogueFormControls) => ReactNode
cancelText?: ReactNode
onCancel?: () => Promise<boolean | void> | boolean | void
footerLayout?: 'inline'
size?: 'default' | 'large' | 'viewport' }
size?: 'default' | 'large' }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
+9 -8
ファイルの表示
@@ -17,6 +17,11 @@ export const fetchLocales = (): Promise<{ locales: AvailableLocale[] }> =>
export const LOCALE_LABELS: Record<LocaleCode, string> = {
ja: '日本語', 'ain-Kana': 'ヤウンモシルン アイヌ イタㇰ' }
const LEGACY_ROOTS = [
'posts', 'tags', 'nico', 'theatres', 'materials', 'wiki', 'help',
'users', 'settings', 'tos', 'gekanator', 'more']
export const isLocaleCode = (value: unknown): value is LocaleCode =>
SUPPORTED_LOCALES.some (locale => locale === value)
@@ -68,15 +73,11 @@ export const switchLocalePath = (
`/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }`
// Keep automatic selection independent of the page and API route trees.
export const automaticallySelectedLocale = (): LocaleCode => 'ja'
export const legacyLocalePath = (
location: { pathname: string; search: string; hash: string },
isPage: (pathname: string) => boolean = () => false,
): string | null => {
if (localeFromPath (location.pathname) != null || !isPage (location.pathname))
return null
return switchLocalePath (location, automaticallySelectedLocale ())
const root = location.pathname.split ('/')[1]
return (location.pathname === '/' || LEGACY_ROOTS.includes (root))
? `/ja${ location.pathname }${ location.search }${ location.hash }`
: null
}
+4 -5
ファイルの表示
@@ -1,5 +1,4 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import { queryRecordParams, setQueryRecords } from '@/lib/queryState'
import { switchLocalePath } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
@@ -18,7 +17,7 @@ export const fetchPosts = async (
...(url && { url }),
...(title && { title }),
...(tags && { tags }),
...(tagReferences && queryRecordParams (new URLSearchParams (tagReferences), 'tag_refs')),
...(tagReferences && { tag_refs: tagReferences }),
...(match && { match }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
@@ -46,14 +45,14 @@ export const switchPostFilterLocale = async (
negative?: boolean
missing?: boolean }[] }> ('/posts/tag_filters', {
locale, params: { tags, target_locale: targetLocale,
...queryRecordParams (query, 'tag_refs') } })
tag_refs: query.get ('tag_refs') ?? undefined } })
query.set ('tags', references.map (reference => reference.name).join (' '))
setQueryRecords (query, 'tag_refs', references.map (reference => ({
query.set ('tag_refs', JSON.stringify (references.map (reference => ({
name: reference.name,
negative: reference.negative,
...(reference.tagId != null && { tag_id: reference.tagId }),
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
...(reference.missing && { missing: true }) })))
...(reference.missing && { missing: true }) }))))
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
}
+2 -3
ファイルの表示
@@ -1,4 +1,3 @@
import { queryRecordParams } from '@/lib/queryState'
import { QueryClient } from '@tanstack/react-query'
import { match } from 'path-to-regexp'
@@ -176,8 +175,8 @@ const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
const keys = {
tags, match: m, page, limit, url: qURL, title,
...(url.searchParams.has ('tag_refs') && {
tagReferences: new URLSearchParams (queryRecordParams (url.searchParams, 'tag_refs')).toString () }),
...(url.searchParams.get ('tag_refs') && {
tagReferences: url.searchParams.get ('tag_refs')! }),
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order }
-5
ファイルの表示
@@ -161,8 +161,3 @@ export const theatresKeys = {
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
programmes: (id: string, locale: LocaleCode) =>
['theatres', locale, id, 'programmes'] as const }
export const countsKeys = {
definition: (resource: string) => ['counts', 'definition', resource] as const,
index: (resource: string, params: string, locale: LocaleCode) =>
['counts', resource, locale, params] as const }
-105
ファイルの表示
@@ -1,105 +0,0 @@
// 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}.`)))
-22
ファイルの表示
@@ -1,22 +0,0 @@
import { useReducedMotion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { clientAnimationTransition, clientMotionTokens } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
export const useClientMotion = () => {
const settings = useClientBehaviourSettings ()
const osReduced = useReducedMotion ()
const [hydrated, setHydrated] = useState (false)
useEffect (() => setHydrated (true), [])
const mode = hydrated ? (osReduced ? 'reduced' : settings.animation ?? 'normal') : 'normal'
const reduced = mode !== 'normal'
const duration = (kind: keyof typeof clientMotionTokens.durationMS) =>
reduced ? 0 : clientMotionTokens.durationMS[kind]
const transition = (kind: keyof typeof clientMotionTokens.durationMS) =>
clientAnimationTransition (mode, {
normal: { duration: duration (kind) / 1000, ease: clientMotionTokens.easing },
reduced: { duration: 0 } })
return { reduced, duration, transition, easing: clientMotionTokens.cssEasing }
}
+37 -41
ファイルの表示
@@ -17,26 +17,24 @@ export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
type DatePrecision = 'month' | 'day' | 'hour' | 'minute' | 'second' | null
const dateFormatters = new Map<string, Intl.DateTimeFormat> ()
const ainuDateFormatters = new Map<string, Intl.DateTimeFormat> ()
const dateFormatters = new Map<DatePrecision, Intl.DateTimeFormat> ()
let ainuDateFormatter: Intl.DateTimeFormat | null = null
const formatDateString = (
date: Date,
unknown: DatePrecision,
locale: LocaleCode,
timeZone = 'Asia/Tokyo',
): string => {
const key = `${timeZone}/${unknown}`
if (Number.isNaN (date.getTime ()))
return date.toString ()
if (locale === 'ja')
{
if (!dateFormatters.has (key))
if (!dateFormatters.has (unknown))
{
dateFormatters.set (key, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', {
dateFormatters.set (unknown, new Intl.DateTimeFormat ('ja-JP-u-ca-japanese', {
era: 'long', year: 'numeric',
timeZone,
timeZone: 'Asia/Tokyo',
month: unknown === 'month' ? undefined : 'long',
day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric',
weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short',
@@ -44,15 +42,14 @@ const formatDateString = (
minute: unknown == null || unknown === 'second' ? 'numeric' : undefined,
second: unknown == null ? 'numeric' : undefined }))
}
return dateFormatters.get (key)!.format (date)
return dateFormatters.get (unknown)!.format (date)
}
if (!ainuDateFormatters.has (timeZone))
ainuDateFormatters.set (timeZone, new Intl.DateTimeFormat ('en-CA', {
ainuDateFormatter ??= new Intl.DateTimeFormat ('en-CA', {
year: 'numeric', month: '2-digit', day: '2-digit',
timeZone,
timeZone: 'Asia/Tokyo',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23' }))
const parts = ainuDateFormatters.get (timeZone)!.formatToParts (date)
hourCycle: 'h23' })
const parts = ainuDateFormatter.formatToParts (date)
const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? ''
const omitDay = unknown != null && ['month', 'day'].includes (unknown)
const dateParts = [part ('year'),
@@ -79,8 +76,7 @@ export const localisedDateString = (
value: string | Date,
locale: LocaleCode,
unknown: DatePrecision = null,
timeZone?: string,
): string => formatDateString (toDate (value), unknown, locale, timeZone)
): string => dateString (value, unknown, locale)
export const originalCreatedAtString = (
@@ -95,36 +91,36 @@ export const originalCreatedAtString = (
const unknownLabel = (key: string) => i18n.t (`history:${key}`, { lng: locale })
const space = locale === 'ja' ? ' ' : '\u3000'
const fromParts = from == null ? null : new Date (from.getTime () + 9 * 3_600_000)
const beforeParts = before == null ? null : new Date (before.getTime () + 9 * 3_600_000)
if (from && before && fromParts && beforeParts)
{
const diff = before.getTime () - from.getTime ()
if (diff <= 60_000 /* 1 分 */)
return format (from, 'second')
if (fromParts.getUTCMinutes () === 0 && beforeParts.getUTCMinutes () === 0)
if (from && before)
{
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000)
return `${format (from, 'minute')}${space}(${unknownLabel ('unknownMinute')})`
if (fromParts.getUTCHours () === 0 && beforeParts.getUTCHours () === 0)
{
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
return `${format (from, 'hour')}${space}(${unknownLabel ('unknownTime')})`
if (fromParts.getUTCDate () === 1 && beforeParts.getUTCDate () === 1)
const diff = before.getTime () - from.getTime ()
if (diff <= 60_000 /* 1 分 */)
return format (from, 'second')
if (from.getMinutes () === 0 && before.getMinutes () === 0)
{
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
return `${format (from, 'day')}${space}(${unknownLabel ('unknownDay')})`
if (fromParts.getUTCMonth () === 0 && beforeParts.getUTCMonth () === 0
&& (31_449_600_000 /* 364 日 */ <= diff
&& diff < 31_708_800_000 /* 367 日 */))
{
return `${format (from, 'month')}${space}(${unknownLabel ('unknownMonthDay')})`
}
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000)
return `${format (from, 'minute')}${space}(${unknownLabel ('unknownMinute')})`
if (from.getHours () === 0 && before.getHours () === 0)
{
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
return `${format (from, 'hour')}${space}(${unknownLabel ('unknownTime')})`
if (from.getDate () === 1 && before.getDate () === 1)
{
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
return `${format (from, 'day')}${space}(${unknownLabel ('unknownDay')})`
if (from.getMonth () === 0 && before.getMonth () === 0
&& (31_449_600_000 /* 364 日 */ <= diff
&& diff < 31_708_800_000 /* 367 日 */))
return `${format (from, 'month')}${space}(${unknownLabel ('unknownMonthDay')})`
}
}
}
}
}
}
const rtn = ([from ? format (from, 'second') : '',
'〜',
-3
ファイルの表示
@@ -1,3 +0,0 @@
{
"_untranslated": ["*"]
}
-252
ファイルの表示
@@ -1,252 +0,0 @@
{
"titles": {
"posts": "広場の集計・明細",
"tags": "タグの集計・明細",
"nico_tags": "ニコニコタグの集計・明細",
"tag_descriptions": "タグ説明の集計・明細",
"tag_groups": "タググループの集計・明細",
"materials": "素材の集計・明細"
},
"target": "集計定義",
"run": "集計実行",
"conditions": "集計条件",
"output": "出力項目",
"filters": "抽出条件",
"mode": "表示",
"exportProgress": {
"title": "エクスポート",
"format": "形式:{{format}}",
"starting": "開始中",
"queued": "待機中",
"running": "生成中",
"cancelling": "取消処理中",
"cancelled": "取消済み",
"ready": "ファイル生成完了・ダウンロード開始(保存完了は未確認)",
"failed": "エラー",
"processed": "{{count}}件処理済み",
"measured": "{{processed}} / {{total}}件({{percent}}%)"
},
"displaySettings": "表示設定",
"summary": "集計",
"details": "明細",
"axis": "集計軸",
"axes": {
"month": "月別(UTC)",
"user": "$t(history:actor)",
"tags": "タグ別"
},
"metric": "履歴レコード件数",
"total": "履歴レコード件数:{{count}}",
"csv": "CSV",
"csvScope": "表示中のページ・選択列を CSV 出力",
"empty": "該当する履歴はありません。",
"columns": {
"changedAt": "操作日時",
"version": "版",
"name": "名称",
"resourceId": "Id.",
"postTitle": "タイトル",
"postURL": "URL",
"tags": "タグ",
"deprecated": "廃止状態",
"latest": "最新フラグ",
"thumbnailURL": "サムネール元 URL",
"originalFrom": "オリジナルの投稿日時(以降)",
"originalBefore": "オリジナルの投稿日時(より前)",
"language": "言語",
"kind": "内部・外部",
"sections": "記載区間",
"names": "言語別名称",
"thumbnailDisplay": "$t(shared:labels.thumbnail)"
},
"fields": {
"from": "開始(端末の時刻帯・以上)",
"before": "終了(端末の時刻帯・未満)",
"post": "投稿 ID",
"tag": "タグ名または ID",
"actor": "操作者 ID",
"limit": "表示件数"
},
"latestOnly": "最新のみ",
"truthValues": {
"true": "真",
"false": "偽"
},
"boolean": {
"true": "はい",
"false": "いいえ"
},
"kinds": {
"internal": "内部タグ",
"external": "外部タグ"
},
"nested": {
"toggle": "表内表を展開・折り畳み",
"selection": "親を OFF にしても子項目の選択は保持します。",
"noColumns": "出力項目が選択されていません。",
"asJSON": "内表を JSON 表示"
},
"sort": {
"action": "クリックで単独ソート。Ctrl または長押しでキーを追加・方向変更・解除。NULL は末尾。",
"structured": "親表の複数値は要素数、保存スナップショットの文字列表現の順で比較します。Ctrl または長押しで複数キーソート。子表のソートは親表に影響しません。",
"label": "{{column}}:{{direction}}、優先順位 {{priority}}",
"none": "ソートなし",
"ascending": "昇順",
"descending": "降順",
"unsortedLabel": "{{column}}:ソートなし",
"singleLabel": "{{column}}:{{direction}}"
},
"notRegistered": "名称未登録",
"selection": {
"table": "集計・明細表",
"hint": "クリックでセル選択、ドラッグまたは Shift+クリックで範囲選択。タッチではセルを長押ししてドラッグ。Ctrl+C/Cmd+C でコピー。矢印キーで移動、Shift+矢印キーで範囲を拡張、Escape で解除。",
"copy": "コピー",
"copyHeaders": "見出し付きコピー",
"row": "行を選択",
"column": "列を選択",
"all": "すべて選択"
},
"resize": {
"column": "{{column}}の列幅を変更。境界をドラッグ、タッチでは長押ししてドラッグ。矢印キーでも調整できます。"
},
"latestValue": "最新",
"sections": {
"range": "{{begin}}~{{end}}",
"separator": "、"
},
"reorder": "{{column}}:出力項目の並べ替え",
"state": {
"list": "タグ別投稿件数一覧",
"trend": "タグ別投稿件数推移",
"details": "時点明細",
"history": "履歴集計",
"historyDetails": "履歴明細",
"at": "集計時点",
"boundary": "集計時点(UTC)",
"period": "時点(UTC)",
"grain": "粒度(UTC)",
"grains": {
"day": "日別",
"month": "月別",
"year": "年別"
},
"postCount": "投稿件数",
"totals": {
"posts": "投稿総数",
"tags": "タグ総数(内部タグ)",
"assignments": "タグ付け総件数"
}
},
"measure": "指標",
"export": "エクスポート",
"exportFormat": "エクスポート形式",
"exportFormats": {
"json": "JSON",
"csv": "CSV"
},
"resources": {
"posts": "広場",
"tags": "タグ",
"nico_tags": "ニコニコタグ",
"tag_descriptions": "タグ説明",
"tag_groups": "タググループ",
"materials": "素材"
},
"latest": "最新",
"history": "履歴",
"referenceAt": "基準日時",
"referenceHelp": "指定した日時における各データの最新版を集計・表示します。空欄の場合は現在の最新版を使用します。",
"from": "開始",
"through": "終了",
"axisItems": "項目一覧",
"rows": "行",
"cols": "列",
"axisPlace": "{{field}}を{{target}}に配置",
"field": "項目",
"operator": "演算子",
"add": "追加",
"remove": "削除",
"value": "値",
"blank": "空欄",
"additionalConditions": "追加条件",
"changedField": "変更項目",
"eventType": "異動区分",
"count": "件数",
"outputTotal": "綜件数:{{value}} 件",
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
"recordTotal": "閲覧可能件数:{{count}}",
"grain": "粒度",
"grains": {
"year": "年",
"quarter": "四半期",
"month": "月",
"week": "週",
"day": "日",
"hour": "時"
},
"operators": {
"eq": "等しい",
"ne": "等しくない",
"gt": "より大きい",
"lt": "未満",
"ge": "以上",
"le": "以下",
"like": "パターン",
"range": "範囲"
},
"events": {
"create": "起票",
"update": "異動",
"discard": "除票",
"restore": "恢復"
},
"attributes": {
"sourceLanguage": "翻訳元の言語",
"source_language_code": "翻訳元の言語",
"source_version_no": "翻訳元の版番号",
"event_type": "異動区分",
"body": "本文",
"linked_tags": "対応タグ",
"description": "説明",
"members": "構成要素",
"placements": "配置",
"parent_id": "親素材",
"tag_id": "タグ",
"tag_name": "タグ名称",
"tag_category": "タグカテゴリ",
"discarded_at": "破棄日時",
"updated_at": "履歴行更新日時",
"updated_by_user_id": "履歴行更新者",
"export_paths_json": "出力先",
"file_blob_id": "ファイル識別子",
"file_filename": "ファイル名",
"file_content_type": "ファイル形式",
"file_byte_size": "ファイル容量",
"file_checksum": "チェックサム",
"file_sha256": "SHA-256",
"source_kind": "取得元の種類",
"source_uri": "取得元 URI",
"source_path": "取得元のパス",
"source_file_id": "取得元ファイル識別子",
"normalized_source_key": "取得元識別キー",
"parent_post_ids": "親投稿",
"parent_tag_ids": "上位タグ",
"video_ms": "動画時間",
"original_created_from": "オリジナルの投稿日時(以降)",
"original_created_before": "オリジナルの投稿日時(より前)",
"deprecated_at": "廃止日時",
"section_name": "節の名称",
"children": "子項目",
"profile": "出力設定",
"path": "出力パス"
},
"rowNumberHeading": "No.",
"subtotal": "小計",
"singleTotal": "合計",
"grandTotal": "綜合計",
"rowNumbers": "行番号",
"periodSeparator": "〜",
"bot": "$t(history:bot)",
"actorWithId": "{{name}} (#{{id}})",
"axisWithGrain": "{{field}}({{grain}})"
}

変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示