このコミットが含まれているのは:
2026-10-31 04:08:05 +09:00
コミット 6a0f1ec819
23個のファイルの変更、188行の追加、57行の削除
+5
ファイルの表示
@@ -99,6 +99,11 @@ class CountsExportsController < ApplicationController
first = false first = false
end end
end end
rescue IOError, SystemCallError => error
Rails.logger.warn("counts_download_stream_failure: #{ error.class }")
HistoryAccess.set(user: authority) do
CountsExportJobs.download_attempt(job, owner, **arguments, status: 'failed')
end
ensure ensure
stream.close stream.close
end end
+4
ファイルの表示
@@ -18,6 +18,10 @@ class CountsTablesController < ApplicationController
def index def index
resolve_locale! resolve_locale!
render json: CountsTableQuery.new(request.query_parameters).call render json: CountsTableQuery.new(request.query_parameters).call
rescue CountsAxes::TooManyAxes
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
rescue CountsAxes::PrivateCondition
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS }, render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity status: :unprocessable_entity
+8 -3
ファイルの表示
@@ -68,6 +68,14 @@ class CountsAxes
end end
end end
def count_expression
return 'COUNT(DISTINCT v.id)' unless rows.any? { _1['field'] == 'tags' }
# Keep tag occurrences even when several private tags share one masked label.
"COUNT(DISTINCT v.id, COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id)))"
end
def joins(fields = all.map { _1['field'] }) def joins(fields = all.map { _1['field'] })
result = '' result = ''
if fields.include?('tags') if fields.include?('tags')
@@ -85,9 +93,6 @@ class CountsAxes
condition = @definition.changes.map do |field| condition = @definition.changes.map do |field|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })" "(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
end.join(' OR ') end.join(' OR ')
unless HistoryAccess.admin?
condition = "(#{ condition }) OR NOT (#{ HistoryAccess.sql(@definition.model, 'v') })"
end
result += " JOIN (#{ fields }) changed ON (#{ condition })" result += " JOIN (#{ fields }) changed ON (#{ condition })"
end end
result result
+1 -1
ファイルの表示
@@ -198,7 +198,7 @@ class CountsExportJobs
state['expires_at'] > Time.current.to_f && HistoryAccess.authorised_artifact?(state)) state['expires_at'] > Time.current.to_f && HistoryAccess.authorised_artifact?(state))
if attempt if attempt
return unless state['download_attempt'] == attempt && state['token'] == file_token return unless state['download_attempt'] == attempt && state['token'] == file_token
return unless state['download_status'] == 'pending' return unless ['pending', 'started'].include?(state['download_status'])
state['download_status'] = status if status state['download_status'] = status if status
else else
+1 -1
ファイルの表示
@@ -49,7 +49,7 @@ class CountsPivotTotals
leaf = retained.map { @axes.expressions[_1] } leaf = retained.map { @axes.expressions[_1] }
selected = leaf.each_with_index.map { |expression, index| "#{ expression } AS k#{ index }" } selected = leaf.each_with_index.map { |expression, index| "#{ expression } AS k#{ index }" }
inner = scope.group(*leaf.map { Arel.sql(_1) }) inner = scope.group(*leaf.map { Arel.sql(_1) })
.select(Arel.sql((selected + ['COUNT(DISTINCT v.id) AS count']).join(', '))).to_sql .select(Arel.sql((selected + ["#{ @axes.count_expression } AS count"]).join(', '))).to_sql
coordinates = indexes.map { "k#{ retained.index(_1) }" } coordinates = indexes.map { "k#{ retained.index(_1) }" }
projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" } projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" }
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] + output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
+2
ファイルの表示
@@ -63,6 +63,8 @@ class CountsPopulation
end end
def axis_condition(scope, entry) def axis_condition(scope, entry)
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
key = entry.fetch('field') key = entry.fetch('field')
raise ArgumentError unless definition.axes(history).include?(key) raise ArgumentError unless definition.axes(history).include?(key)
if entry.key?('grain') if entry.key?('grain')
+2 -2
ファイルの表示
@@ -88,7 +88,7 @@ class CountsTableQuery
source.except(:select).select('COUNT(DISTINCT v.id) AS count').to_sql source.except(:select).select('COUNT(DISTINCT v.id) AS count').to_sql
else else
source.except(:select).group(*expressions.map { Arel.sql(_1) }) source.except(:select).group(*expressions.map { Arel.sql(_1) })
.select(Arel.sql((selected + ['COUNT(DISTINCT v.id) AS count']).join(', '))) .select(Arel.sql((selected + ["#{ axes.count_expression } AS count"]).join(', ')))
.to_sql .to_sql
end end
prefix = "WITH cells AS (#{ sql })" prefix = "WITH cells AS (#{ sql })"
@@ -359,7 +359,7 @@ class CountsTableQuery
end end
selected = row_expressions.each_with_index.map { |expression, index| selected = row_expressions.each_with_index.map { |expression, index|
"#{ expression } AS a#{ index }" "#{ expression } AS a#{ index }"
} + ['COUNT(DISTINCT v.id) AS count'] } + ["#{ axes.count_expression } AS count"]
source = source.group(*row_expressions.map { Arel.sql(_1) }) if row_expressions.any? source = source.group(*row_expressions.map { Arel.sql(_1) }) if row_expressions.any?
source.select(Arel.sql(selected.join(', '))).to_sql source.select(Arel.sql(selected.join(', '))).to_sql
end end
+22 -1
ファイルの表示
@@ -232,7 +232,7 @@ RSpec.describe 'Counts table API', type: :request do
it 'does not resolve private condition names even when the result set is empty' do it 'does not resolve private condition names even when the result set is empty' do
TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag', TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
category: 'general', aliases: '', parent_tag_ids: '', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'secret tag', script_code: 'Jpan', aliases: [] } }, names: { ja: { name: 'secret tag', script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2024, 2)) created_at: Time.utc(2024, 2))
result = query(axis('tags', "internal:#{ tag.id }").merge(definition: 'latest')) result = query(axis('tags', "internal:#{ tag.id }").merge(definition: 'latest'))
expect(result['versions']).to be_empty expect(result['versions']).to be_empty
@@ -245,4 +245,25 @@ RSpec.describe 'Counts table API', type: :request do
end end
end end
end end
it 'includes private history in the public timeline without an exclusion count' do
PostVersion.create!(post: record, version_no: 4, event_type: 'discard', url: record.url,
title: 'hidden title', tags: '', tags_json: [], parent_post_ids: '',
created_at: Time.utc(2024, 3))
result = query(mode: 'summary', rows: 'created_at:month', cols: 'event_type')
expect(result['excluded_count']).to be_nil
expect(result['count']).to eq(4)
expect(result['cells'].sum { _1['count'] }).to eq(4)
expect(response.body).not_to include('hidden title')
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]])
expect(masked['cells'].first['count']).to eq(4)
expect(query['versions']).to be_empty
end
it 'rejects private summary predicates instead of exposing a count oracle' do
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s)
expect(response).to have_http_status(:forbidden)
expect(json).to eq('error' => 'private_condition')
end
end end
+13 -26
ファイルの表示
@@ -21,29 +21,7 @@ RSpec.describe 'Post filter identity across locales', type: :request do
['nico:shared', 'union_alias', 'not:nico:shared'].each do |literal| ['nico:shared', 'union_alias', 'not:nico:shared'].each do |literal|
it "preserves the complete match set for #{ literal }" do it "preserves the complete match set for #{ literal }" do
reference_params = { tag_refs: '' } get '/posts', params: { locale: 'ja', tags: literal }
references.each_with_index do |reference, index|
%w[name tag_id external_tag_id negative missing].each do |field|
value = reference[field]
next if value.nil?
next if field == 'missing' && value == false
reference_params["tag_refs.#{index}.#{field}"] =
case value
when true
'1'
when false
'0'
else
value.to_s
end
end
end
get '/posts', params: {
locale: 'ain-Kana',
tags: references.map { _1.fetch('name') }.join(' ') }.merge(reference_params)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
original_ids = json.fetch('posts').map { _1.fetch('id') } original_ids = json.fetch('posts').map { _1.fetch('id') }
expected = literal.start_with?('not:') ? [other_post.id] : expected = literal.start_with?('not:') ? [other_post.id] :
@@ -56,9 +34,18 @@ RSpec.describe 'Post filter identity across locales', type: :request do
references = json.fetch('references') references = json.fetch('references')
expect(references.first).to include('tag_id' => tag.id, 'external_tag_id' => external.id) expect(references.first).to include('tag_id' => tag.id, 'external_tag_id' => external.id)
get '/posts', params: { reference_params = { tag_refs: '' }
locale: 'ain-Kana', tags: references.map { _1.fetch('name') }.join(' '), references.each_with_index do |reference, index|
tag_refs: references.to_json } ['name', 'tag_id', 'external_tag_id', 'negative', 'missing'].each do |field|
value = reference[field]
next if value.nil? || (field == 'missing' && value == false)
reference_params["tag_refs.#{ index }.#{ field }"] =
[true, false].include?(value) ? (value ? '1' : '0') : value.to_s
end
end
get '/posts', params: { locale: 'ain-Kana',
tags: references.map { _1.fetch('name') }.join(' ') }.merge(reference_params)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids) expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids)
end end
+1 -1
ファイルの表示
@@ -63,7 +63,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
end end
it 'does not fall back when the new current revision is missing' do it 'does not fall back when the new current revision is missing' do
versions.last.destroy! TagDescriptionVersion.where(id: versions.last.id).delete_all
without_legacy_reads { get "/wiki/#{ description.id }" } without_legacy_reads { get "/wiki/#{ description.id }" }
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
+30
ファイルの表示
@@ -171,3 +171,33 @@ RSpec.describe 'Counts export contracts' do
end end
end end
end end
RSpec.describe 'Counts download acknowledgement' do
around do |example|
Dir.mktmpdir('counts-ack-spec') do |path|
allow(CountsExportDownload).to receive(:directory).and_return(Pathname.new(path))
HistoryAccess.set(user: nil) { example.run }
end
end
it 'binds an acknowledgement to the owner, job, file token and fresh attempt' do
CountsExportJobs.pulse
payload = { 'resource' => 'posts', 'definition' => 'history', 'mode' => 'details',
'format' => 'json', 'locale' => 'ja' }
token = CountsExportJobs.start(payload, 'owner', 'ja')
id = CountsExportJobs.authorised_id(token, 'owner')
state = CountsExportJobs.read(id).merge('status' => 'ready', 'token' => 'file-token')
CountsExportJobs.write(state)
receipt = CountsExportJobs.download_attempt(token, 'owner')
expect(receipt['download_status']).to eq('pending')
arguments = { file_token: 'file-token', attempt: receipt.fetch('download_attempt') }
expect(CountsExportJobs.download_attempt(token, 'other', **arguments)).to be_nil
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments.merge(file_token: 'wrong'))).to be_nil
CountsExportJobs.download_attempt(token, 'owner', **arguments, status: 'started')
expect(CountsExportJobs.status(token, 'owner')['download_status']).to eq('started')
CountsExportJobs.download_attempt(token, 'owner')
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments)).to be_nil
CountsExportJobs.status(token, 'owner', cancel: true)
expect(CountsExportJobs.download_attempt(token, 'owner')).to be_nil
end
end
+20
ファイルの表示
@@ -105,3 +105,23 @@ CSV の物理行と JSON の pivot_cell には集計レコードを含むため
追加は許可後の新規 add_index migration として行い、既存 migration は変更しない。 追加は許可後の新規 add_index migration として行い、既存 migration は変更しない。
実DBでのインデックス容量・作成時ロック・実行計画・時間は未確認。 実DBでのインデックス容量・作成時ロック・実行計画・時間は未確認。
索引追加なしでの最大データ量に対する応答時間は保証しない。 索引追加なしでの最大データ量に対する応答時間は保証しない。
## GET 応答開始の確認(静的実装・未実測)
進捗画面は ready 後に owner/job に紐づく download_attempt を登録し、同じ attempt を
native iframe の実 GET に渡す。サーバーは署名・所有者・権限 revision・期限・ファイルを
再検証し、64KiB の最初のチャンクを Rack 出力側へ渡した後に started を記録する。
HEAD や受付 POST では started にしない。確認には30秒の独立した期限がある。
サーバーの出力開始はブラウザー受信・保存完了を保証しない。途中の切断もあり得る。
開始確認前の失敗はダイアログに残る。再実行はダイアログを閉じてエクスポートをやり直す。
Web と worker は同じ COUNTS_EXPORT_DIRECTORY、Rails secret、DB、実行ユーザーで
共有 POSIX filesystem を使うこと。別ホストではローカル tmp を共有できない。
systemd の EnvironmentFile と Web の環境を照合し、directory の owner と0700相当の
アクセス制限、容量、期限切れ削除、worker heartbeat と restart を公開前に確認する。
この作業では systemctl・worker 起動・本番環境への適用を行っていない。
リリース保留:非公開グループと複数値・変更項目軸の交差集計、生成ファイルへの秘匿値の
非混入、実 GET の middleware/proxy 経由の開始通知は、回帰テストと実環境の確認が必要。
テスト・型チェック・build は未実行。
+1 -1
ファイルの表示
@@ -51,7 +51,7 @@ const CountsAxes = ({ params, update }: {
return <SortableList groups={groups} name={name} onChange={commit} return <SortableList groups={groups} name={name} onChange={commit}
renderGroup={(id, content, over) => <fieldset className="mb-2 min-w-0"> renderGroup={(id, content, over) => <fieldset className="mb-2 min-w-0">
<legend>{label (id === 'available' ? 'axisItems' : id)}</legend> <legend>{label (id === 'available' ? 'axisItems' : id)}</legend>
<div className={cn ('rounded border dark:border-slate-700', <div className={cn ('rounded border px-1 dark:border-slate-700',
over && 'ring-1 ring-blue-500 dark:ring-blue-400')}>{content}</div> over && 'ring-1 ring-blue-500 dark:ring-blue-400')}>{content}</div>
</fieldset>} renderItem={(id, group) => { </fieldset>} renderItem={(id, group) => {
const axis = axes.find (axis => axis.field === id) const axis = axes.find (axis => axis.field === id)
+3 -3
ファイルの表示
@@ -185,16 +185,16 @@ const CountsControls = (
className={inputClass (false, 'w-20 py-1')} className={inputClass (false, 'w-20 py-1')}
onChange={event => update ('limit', event.target.value)}> onChange={event => update ('limit', event.target.value)}>
{[20, 50, 100].map (count => <option key={count} value={count}> {[20, 50, 100].map (count => <option key={count} value={count}>
{label ('shared:labels.resultsCount', { count })}</option>)} {message ('shared:labels.resultsCount', { count }).text}</option>)}
</select> </select>
</label> </label>
<label className="inline-flex items-center gap-1 text-sm"> {details && <label className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={draft.get ('row_numbers') === '1'} <input type="checkbox" checked={draft.get ('row_numbers') === '1'}
onChange={event => { onChange={event => {
update ('row_numbers', event.target.checked ? '1' : '0') update ('row_numbers', event.target.checked ? '1' : '0')
displayRowNumbers (event.target.checked) displayRowNumbers (event.target.checked)
}}/>{label ('rowNumbers')} }}/>{label ('rowNumbers')}
</label> </label>}
{details && resource === 'posts' && <label {details && resource === 'posts' && <label
className="inline-flex items-center gap-1 text-sm"> className="inline-flex items-center gap-1 text-sm">
<input type="checkbox" checked={thumbnail} <input type="checkbox" checked={thumbnail}
+20 -8
ファイルの表示
@@ -32,8 +32,10 @@ const CountsExportProgress = (
const job = useRef<string | null> (null) const job = useRef<string | null> (null)
const creation = useRef<Promise<{ job: string }> | null> (null) const creation = useRef<Promise<{ job: string }> | null> (null)
const downloaded = useRef (false) const downloaded = useRef (false)
const registration = useRef (false)
const attempt = useRef<string | null> (null) const attempt = useRef<string | null> (null)
const downloadDeadline = useRef (0) const downloadDeadline = useRef (0)
const acknowledgementTimer = useRef<ReturnType<typeof setTimeout> | null> (null)
const releaseDownload = useRef<(() => void) | null> (null) const releaseDownload = useRef<(() => void) | null> (null)
const [downloadError, setDownloadError] = useState<string | null> (null) const [downloadError, setDownloadError] = useState<string | null> (null)
const pollRequest = useRef<AbortController | null> (null) const pollRequest = useRef<AbortController | null> (null)
@@ -79,25 +81,33 @@ const CountsExportProgress = (
const response = await apiGet<ExportStatus> ( const response = await apiGet<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}`, { `/counts/export/jobs/${encodeURIComponent (token)}`, {
locale, signal: controller.signal }) locale, signal: controller.signal })
if (!active) return if (!active || terminal.current) return
const result = (cancelled.current && response.status === 'ready') const result = (cancelled.current && response.status === 'ready')
? await apiDelete<ExportStatus> ( ? await apiDelete<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}`, { `/counts/export/jobs/${encodeURIComponent (token)}`, {
locale, signal: controller.signal }) : response locale, signal: controller.signal }) : response
if (!active) return if (!active || terminal.current) return
lastResponse = Date.now () lastResponse = Date.now ()
setState (result) setState (result)
terminal.current = ['failed', 'cancelled'].includes (result.status) terminal.current = ['failed', 'cancelled'].includes (result.status)
if (result.status === 'ready' && result.token != null && !downloaded.current) if (result.status === 'ready' && result.token != null && !downloaded.current)
{ {
downloaded.current = true registration.current = true
const receipt = await apiPost<ExportStatus> ( const receipt = await apiPost<ExportStatus> (
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, { `/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
locale, signal: controller.signal }) locale, signal: controller.signal })
if (!active || cancelled.current) return if (!active || cancelled.current) return
if (receipt.downloadAttempt == null) throw new Error ('Missing download attempt') if (receipt.downloadAttempt == null) throw new Error ('Missing download attempt')
downloaded.current = true
attempt.current = receipt.downloadAttempt attempt.current = receipt.downloadAttempt
downloadDeadline.current = Date.now () + 30_000 downloadDeadline.current = Date.now () + 30_000
acknowledgementTimer.current = setTimeout (() => {
if (!active || terminal.current) return
terminal.current = true
pollRequest.current?.abort ()
releaseDownload.current?.()
setDownloadError ('timeout')
}, 30_000)
releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => { releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => {
if (active) if (active)
{ {
@@ -132,13 +142,13 @@ const CountsExportProgress = (
} }
catch (error) catch (error)
{ {
if (active) if (active && !terminal.current)
{ {
if ((isApiError (error) && error.response?.status === 404) if (downloaded.current || registration.current || (isApiError (error) && error.response?.status === 404)
|| Date.now () - lastResponse > 30_000) || Date.now () - lastResponse > 30_000)
{ {
terminal.current = true terminal.current = true
if (downloaded.current) setDownloadError ('unavailable') setDownloadError ('unavailable')
setState (previous => ({ ...previous, status: 'failed' })) setState (previous => ({ ...previous, status: 'failed' }))
return return
} }
@@ -160,13 +170,14 @@ const CountsExportProgress = (
locale, signal: controller.signal }).finally (() => clearTimeout (deadline)) locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
} }
creation.current.then (async result => { creation.current.then (async result => {
if (!active) return if (!active || terminal.current) return
job.current = result.job job.current = result.job
if (cancelled.current) await handle.cancel () if (cancelled.current) await handle.cancel ()
await poll (result.job) await poll (result.job)
}).catch (() => { }).catch (() => {
if (active) if (active)
{ {
setDownloadError ('failed')
terminal.current = true terminal.current = true
setState (previous => ({ ...previous, status: 'failed' })) setState (previous => ({ ...previous, status: 'failed' }))
} }
@@ -176,8 +187,9 @@ const CountsExportProgress = (
if (timer != null) clearTimeout (timer) if (timer != null) clearTimeout (timer)
pollRequest.current?.abort () pollRequest.current?.abort ()
releaseDownload.current?.() releaseDownload.current?.()
if (acknowledgementTimer.current != null) clearTimeout (acknowledgementTimer.current)
} }
}, [handle, locale, payload, controls]) }, [handle, locale])
useEffect (() => { useEffect (() => {
controls.setActions ([]) controls.setActions ([])
}, [controls]) }, [controls])
+5 -1
ファイルの表示
@@ -3,6 +3,7 @@ import { useContext, useEffect, useRef } from 'react'
import SortableItem from '@/components/common/SortableItem' import SortableItem from '@/components/common/SortableItem'
import CountsResizeHandle from '@/components/counts/CountsResizeHandle' import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation' import { useUITranslation } from '@/lib/useUITranslation'
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout' import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
@@ -129,7 +130,10 @@ const CountsSortHeader = (
} }
change (field, event.ctrlKey || event.metaKey) change (field, event.ctrlKey || event.metaKey)
}}> }}>
<span data-counts-label className="truncate" lang={label.language}>{label.text}</span> <span data-counts-label className={cn ('truncate', label.text === '********'
&& 'text-red-600 dark:text-red-400')}
title={label.text === '********' ? message ('maskedReason').text : undefined}
lang={label.language}>{label.text}</span>
<span aria-hidden data-counts-sort-indicator className="inline-block w-10 shrink-0"> <span aria-hidden data-counts-sort-indicator className="inline-block w-10 shrink-0">
{priority >= 0 && <>{direction === 'asc' ? '▲' : '▼'} {priority >= 0 && <>{direction === 'asc' ? '▲' : '▼'}
{sorts.length > 1 && priority + 1}</>}</span> {sorts.length > 1 && priority + 1}</>}</span>
+7 -4
ファイルの表示
@@ -26,7 +26,7 @@ const CountsSummaryTable = (
const definition = useCountsDefinition () const definition = useCountsDefinition ()
const locale = useLocale () const locale = useLocale ()
const { message } = useUITranslation ('counts') const { message } = useUITranslation ('counts')
const numbered = params.get ('row_numbers') === '1' const numbered = false
const rows = data.rows ?? [] const rows = data.rows ?? []
const cols = data.cols ?? [] const cols = data.cols ?? []
const cells = data.cells ?? [] const cells = data.cells ?? []
@@ -260,7 +260,9 @@ const CountsSummaryTable = (
data-counts-total={row.kind !== 'data' ? row.kind : undefined} data-counts-total={row.kind !== 'data' ? row.kind : undefined}
data-counts-row-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', className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td> numbered && offset === 0 && 'text-right tabular-nums')}>{value === '********' ? <span
className="text-red-600 dark:text-red-400"
title={message ('maskedReason').text}>{value}</span> : value}</td>
})} })}
{columnHeaders.map (column => <td key={columnKey (column)} {columnHeaders.map (column => <td key={columnKey (column)}
data-counts-copy={String (count (row, column))} data-counts-copy={String (count (row, column))}
@@ -271,11 +273,12 @@ const CountsSummaryTable = (
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data') data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
? '' : undefined} ? '' : undefined}
className="border px-2 py-1 dark:border-slate-700"> className="border px-2 py-1 dark:border-slate-700">
<PrefetchLink to={drill (row, column)} aria-label={ {[...row.values, ...column.values].includes ('__counts_private__')
? <span>{count (row, column)}</span> : <PrefetchLink to={drill (row, column)} aria-label={
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
: `${message (totalLabels.get ( : `${message (totalLabels.get (
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}> JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
{count (row, column)}</PrefetchLink> {count (row, column)}</PrefetchLink>}
</td>)} </td>)}
</tr>)}</tbody> </tr>)}</tbody>
</CountsSelectableTable> </CountsSelectableTable>
+1
ファイルの表示
@@ -488,6 +488,7 @@ export const countsSummaryValue = (
): string => { ): string => {
const message = (key: string, values?: Record<string, string | number>) => const message = (key: string, values?: Record<string, string | number>) =>
translateUIMessage (key, 'counts', locale, values).text translateUIMessage (key, 'counts', locale, values).text
if (value === '__counts_private__') return '********'
if (value == null) return axis.field === 'user' ? message ('bot') : '' if (value == null) return axis.field === 'user' ? message ('bot') : ''
if (['language_code', 'source_language_code'].includes (axis.field)) if (['language_code', 'source_language_code'].includes (axis.field))
return languageUIMessage (String (value), locale).text return languageUIMessage (String (value), locale).text
+17 -1
ファイルの表示
@@ -1,3 +1,19 @@
{ {
"_untranslated": ["*"] "_untranslated": [
"*",
"outputRows",
"maskedReason",
"downloadErrors",
"axisLimit",
"privateCondition"
],
"outputRows": "行数:{{value}} 件",
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
"downloadErrors": {
"unavailable": "ダウンロードの認可が失われたか、ファイルの有効期限が切れています。",
"failed": "ダウンロード応答を開始できませんでした。",
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
},
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
} }
+10 -1
ファイルの表示
@@ -290,5 +290,14 @@
"periodSeparator": "〜", "periodSeparator": "〜",
"bot": "$t(history:bot)", "bot": "$t(history:bot)",
"actorWithId": "{{name}} (#{{id}})", "actorWithId": "{{name}} (#{{id}})",
"axisWithGrain": "{{field}}({{grain}})" "axisWithGrain": "{{field}}({{grain}})",
"outputRows": "行数:{{value}} 件",
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
"downloadErrors": {
"unavailable": "ダウンロードの認可が失われたか、ファイルの有効期限が切れています。",
"failed": "ダウンロード応答を開始できませんでした。",
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
},
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
} }
+1
ファイルの表示
@@ -26,6 +26,7 @@ const renderPage = (extra = '') => renderWithProviders (<>
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` }) </>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
beforeEach (() => { beforeEach (() => {
vi.spyOn (HTMLCanvasElement.prototype, 'getContext').mockReturnValue (null)
fetchCounts.mockReset ().mockResolvedValue (detailResult) fetchCounts.mockReset ().mockResolvedValue (detailResult)
fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition) fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition)
}) })
+13 -3
ファイルの表示
@@ -96,6 +96,7 @@ const CountsResourceContent = (
cacheParams.set ('definition', state ? 'latest' : 'history') cacheParams.set ('definition', state ? 'latest' : 'history')
const [running, setRunning] = useState (false) const [running, setRunning] = useState (false)
const [runError, setRunError] = useState (false) const [runError, setRunError] = useState (false)
const [runFailure, setRunFailure] = useState<unknown> (null)
const [runColumnLimit, setRunColumnLimit] = useState<number | null> (null) const [runColumnLimit, setRunColumnLimit] = useState<number | null> (null)
const pendingRun = useRef<PendingCountsRun | null> (null) const pendingRun = useRef<PendingCountsRun | null> (null)
const applied = useRef (params) const applied = useRef (params)
@@ -219,6 +220,7 @@ const CountsResourceContent = (
if (pendingRun.current !== pending) return if (pendingRun.current !== pending) return
pendingRun.current = null pendingRun.current = null
setRunning (false) setRunning (false)
setRunFailure (failure)
setRunColumnLimit (tooManyColumns (failure)) setRunColumnLimit (tooManyColumns (failure))
setRunError (true) setRunError (true)
} }
@@ -228,6 +230,14 @@ const CountsResourceContent = (
const waiting = running || (!definitionError && (definition == null const waiting = running || (!definitionError && (definition == null
|| pending || fetching || placeholder || pending || fetching || placeholder
|| (!params.has ('execution_at') && !errorFlg))) || (!params.has ('execution_at') && !errorFlg)))
const failure = runError ? runFailure : queryError
const errorBody = isApiError (failure) ? failure.response?.data as {
error?: string; max_axes?: number; maxAxes?: number } : undefined
const errorCode = errorBody?.error
const failureMessage = errorCode === 'too_many_axes'
? label ('axisLimit', { max: errorBody?.maxAxes ?? errorBody?.max_axes ?? '' })
: (errorCode === 'private_condition' ? label ('privateCondition')
: label ('common:fetchError'))
const columnLimit = runColumnLimit ?? tooManyColumns (queryError) const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
@@ -371,14 +381,14 @@ const CountsResourceContent = (
<p>{label ('columnLimit.title')}</p> <p>{label ('columnLimit.title')}</p>
<p>{label ('columnLimit.message', { max: columnLimit })}</p> <p>{label ('columnLimit.message', { max: columnLimit })}</p>
</div> : ((definitionError || errorFlg || runError) </div> : ((definitionError || errorFlg || runError)
&& <p role="alert">{label ('common:fetchError')}</p>)} && <p role="alert">{failureMessage}</p>)}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden" <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
aria-busy={waiting}> aria-busy={waiting}>
<div inert={waiting} className="flex min-h-0 flex-1 flex-col"> <div inert={waiting} className="flex min-h-0 flex-1 flex-col">
{definition != null && data != null && <> {definition != null && data != null && <>
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm"> <p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span> <span>{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })}</span>
{data.excludedCountScope === 'current_query' {resultMode === 'details' && data.excludedCountScope === 'current_query'
&& data.excludedCount != null && data.excludedCount > 0 && ( && data.excludedCount != null && data.excludedCount > 0 && (
<span className="text-xs text-slate-500 dark:text-slate-400"> <span className="text-xs text-slate-500 dark:text-slate-400">
{label ('excludedRecords', { count: data.excludedCount })}</span>)} {label ('excludedRecords', { count: data.excludedCount })}</span>)}
+1
ファイルの表示
@@ -22,6 +22,7 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({
describe ('Help global edition history', () => { describe ('Help global edition history', () => {
beforeEach (() => { beforeEach (() => {
window.history.replaceState (null, '', '/ja/help/changes')
helpFixture.reset () helpFixture.reset ()
vi.mocked (apiGet).mockResolvedValue ({ locales: [{ code: 'ja', name: '日本語' }] }) vi.mocked (apiGet).mockResolvedValue ({ locales: [{ code: 'ja', name: '日本語' }] })
}) })