From b4139f83708fca7ac4ffbd2a74b43cf21add46af Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 26 Oct 2026 18:45:28 +0900 Subject: [PATCH] #434 --- .../controllers/counts_exports_controller.rb | 8 +++ .../app/services/counts_export_download.rb | 49 ++++++++++++++++- .../src/components/common/SortableItem.tsx | 11 ++-- .../src/components/common/SortableList.tsx | 54 +++++++++++++++---- .../src/components/counts/CountsControls.tsx | 34 ++++++++---- .../src/components/counts/CountsResults.tsx | 17 ++++-- .../src/components/counts/CountsRowNumber.tsx | 17 ++++++ .../counts/CountsSummaryResults.tsx | 17 ++++-- frontend/src/lib/counts.ts | 23 ++++++-- frontend/src/lib/countsTableLayout.ts | 2 +- frontend/src/lib/useClientMotion.ts | 5 +- frontend/src/locales/ja/counts.json | 3 +- frontend/src/pages/CountsResourcePage.tsx | 4 +- 13 files changed, 199 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/counts/CountsRowNumber.tsx diff --git a/backend/app/controllers/counts_exports_controller.rb b/backend/app/controllers/counts_exports_controller.rb index ca459ad..537d512 100644 --- a/backend/app/controllers/counts_exports_controller.rb +++ b/backend/app/controllers/counts_exports_controller.rb @@ -9,6 +9,14 @@ class CountsExportsController < ApplicationController 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 diff --git a/backend/app/services/counts_export_download.rb b/backend/app/services/counts_export_download.rb index 0dbbd74..b773c5c 100644 --- a/backend/app/services/counts_export_download.rb +++ b/backend/app/services/counts_export_download.rb @@ -4,23 +4,70 @@ 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('tmp', 'counts_exports') def self.verifier = Rails.application.message_verifier('counts_export_download') def self.prepare(exporter, owner) + FileUtils.mkdir_p(directory, mode: 0o700) + slot = acquire_slot + + begin + generate(exporter, owner) + 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) 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 + file = Tempfile.create(['counts-', '.part'], directory) complete = false + bytes = 0 + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MAX_EXPORT_SECONDS path = file.path begin file.binmode - exporter.body.each { file.write(_1) } + exporter.body.each do |chunk| + 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.close finished_path = path.sub(/\.part\z/, '.export') File.rename(path, finished_path) diff --git a/frontend/src/components/common/SortableItem.tsx b/frontend/src/components/common/SortableItem.tsx index ada6307..a57c346 100644 --- a/frontend/src/components/common/SortableItem.tsx +++ b/frontend/src/components/common/SortableItem.tsx @@ -9,9 +9,9 @@ import type { ReactNode } from 'react' type Name = { text: string; language: string } -const SortableItem = ({ id, name, fixed, children, moveGroup }: { +const SortableItem = ({ id, name, fixed, children, moveGroup, handleId }: { id: string; name: Name; fixed?: boolean; children: ReactNode - moveGroup: (direction: number) => void }, + moveGroup: (direction: number) => void; handleId: string }, ) => { const motion = useClientMotion () const item = useSortable ({ id, disabled: fixed, @@ -25,15 +25,16 @@ const SortableItem = ({ id, name, fixed, children, moveGroup }: { item.isDragging && 'opacity-30 ring-2 ring-blue-500 dark:ring-blue-400')}>
{fixed ? : @@ -87,7 +96,14 @@ const CountsControls = ( value={mode} onChange={event => update ('mode', event.target.value)}> {['summary', 'details'].map (value => )} - {actions} + + {actions}
- {columns.length === 0 ?

{label ('nested.noColumns')}

: ( + {tableColumns.length === 0 ?

{label ('nested.noColumns')}

: ( row.id)}`} className="text-left text-sm"> + {rowNumbers && } {columns.map (column => column === 'thumbnail' ? @@ -114,8 +119,10 @@ const CountsResults = ( field => field.key === column)?.children != null}/>)} - {(data.versions ?? []).map (row => ( + {(data.versions ?? []).map ((row, index) => ( + {rowNumbers && } {columns.map (column => ( + {value} + return + {label ('rowNumbers')} + + +} + + +export default CountsRowNumber diff --git a/frontend/src/components/counts/CountsSummaryResults.tsx b/frontend/src/components/counts/CountsSummaryResults.tsx index 30e8b1c..cc1c6b7 100644 --- a/frontend/src/components/counts/CountsSummaryResults.tsx +++ b/frontend/src/components/counts/CountsSummaryResults.tsx @@ -1,4 +1,5 @@ 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' @@ -15,11 +16,13 @@ const CountsSummaryResults = ( pending: boolean; update: (key: string, value: string) => void }, ) => { const { label, message } = useUITranslation ('counts') + const rowNumbers = params.get ('row_numbers') === '1' const rows = data.rows ?? [] const cols = data.cols ?? [] const axes = [...rows, ...cols] const cells = data.cells ?? [] - const rowKeys = [...new Set (cells.map (cell => JSON.stringify (cell.values.slice (0, rows.length))))] + const rowKeys = [...new Set (cells.map (cell => JSON.stringify ( + cell.values.slice (0, rows.length))))] const columnKeys = data.columnValues?.map (values => JSON.stringify (values)) ?? [...new Set (cells.map (cell => JSON.stringify (cell.values.slice (rows.length))))] if (cols.length === 0 && columnKeys.length === 0) columnKeys.push ('[]') @@ -46,12 +49,13 @@ const CountsSummaryResults = ( next.delete ('page') return `/counts/${resource}?${next}` } - const keys = [...rows.map ((_, index) => `a${index}`), + const keys = [...(rowNumbers ? ['row_number'] : []), ...rows.map ((_, index) => `a${index}`), ...columnKeys.map ((_, index) => `cell${index}`)] return
+ {rowNumbers && } {rows.map ((axis, index) => update ('sort', writeCountSort ( @@ -65,16 +69,18 @@ const CountsSummaryResults = ( ...new TextEncoder ().encode (key)].map (byte => byte.toString (16).padStart (2, '0')).join ('')}` return sort.key === sortKey ? { ...sort, key: `cell${index}` } : sort)} change={(_, additive) => update ('sort', writeCountSort ( changeCountSort (sorts, sortKey, additive)))}/> })} - {rowKeys.map (rowKey => { + {rowKeys.map ((rowKey, rowIndex) => { const rowValues = JSON.parse (rowKey) as (string | number | null)[] return + {rowNumbers && } {rowValues.map ((value, index) => @@ -82,7 +88,8 @@ const CountsSummaryResults = ( {columnKeys.map (columnKey => { const columnValues = JSON.parse (columnKey) as (string | number | null)[] const values = [...rowValues, ...columnValues] - const cell = cells.find (cell => JSON.stringify (cell.values) === JSON.stringify (values)) + const cell = cells.find (cell => JSON.stringify ( + cell.values) === JSON.stringify (values)) return {cell?.count ?? 0} diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts index 68b50d9..562c294 100644 --- a/frontend/src/lib/counts.ts +++ b/frontend/src/lib/counts.ts @@ -304,11 +304,18 @@ export const isCountsState = (params: URLSearchParams): boolean => params.get ('definition') !== 'history' +export const countsUseLatest = (params: URLSearchParams): boolean => + params.get ('current') === '1' + || (params.get ('current') !== '0' && !params.get ('at')) + + 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']) + 'nested_json', 'order', 'basis', 'axis', 'latest', 'grain', + 'current', 'row_numbers']) result.delete (key) + if (isCountsState (params) && countsUseLatest (params)) result.delete ('at') return result } @@ -331,10 +338,16 @@ export const exportCounts = ( headers: string[], labels: Record, onError?: () => void, -): Promise => apiDownload ('/counts/export', { - ...Object.fromEntries (params), resource, format, - columns: columns.join (','), headers: JSON.stringify (headers), - labels: JSON.stringify (labels) }, locale, onError) +): Promise => { + const payload = new URLSearchParams (params) + payload.delete ('current') + payload.delete ('row_numbers') + if (isCountsState (params) && countsUseLatest (params)) payload.delete ('at') + return apiDownload ('/counts/export', { + ...Object.fromEntries (payload), resource, format, + columns: columns.join (','), headers: JSON.stringify (headers), + labels: JSON.stringify (labels) }, locale, onError) +} diff --git a/frontend/src/lib/countsTableLayout.ts b/frontend/src/lib/countsTableLayout.ts index 50caf73..f21cb59 100644 --- a/frontend/src/lib/countsTableLayout.ts +++ b/frontend/src/lib/countsTableLayout.ts @@ -4,7 +4,7 @@ export const MIN_COUNT_WIDTH = 64 export const MAX_COUNT_WIDTH = 2000 export const defaultCountWidths: Record = { - thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104, + 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_at: 260, original_created_from: 240, original_created_before: 240, name: 240, names: 480, category: 180, diff --git a/frontend/src/lib/useClientMotion.ts b/frontend/src/lib/useClientMotion.ts index 922c2ad..83f96fd 100644 --- a/frontend/src/lib/useClientMotion.ts +++ b/frontend/src/lib/useClientMotion.ts @@ -1,4 +1,5 @@ import { useReducedMotion } from 'framer-motion' +import { useEffect, useState } from 'react' import { clientAnimationTransition, clientMotionTokens } from '@/lib/clientAnimation' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' @@ -7,7 +8,9 @@ import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' export const useClientMotion = () => { const settings = useClientBehaviourSettings () const osReduced = useReducedMotion () - const mode = osReduced ? 'reduced' : settings.animation ?? 'normal' + 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] diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json index a835f6c..2e32ae3 100644 --- a/frontend/src/locales/ja/counts.json +++ b/frontend/src/locales/ja/counts.json @@ -217,5 +217,6 @@ "children": "子項目", "profile": "出力設定", "path": "出力パス" - } + }, + "rowNumbers": "行番号" } diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index abb050f..4c0a927 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -39,6 +39,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => { const controlParams = new URLSearchParams (params) controlParams.delete ('nested_json') controlParams.delete ('order') + controlParams.delete ('row_numbers') const request = countsRequestParams (params) const [open, setOpen] = useState (false) const [sidebarOpen, setSidebarOpen] = useState (false) @@ -163,7 +164,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
    {['latest', 'history'].map (definition => { const next = new URLSearchParams ({ definition, mode }) - for (const key of ['timezone', 'at']) + for (const key of ['timezone', 'at', 'current']) if (params.get (key)) next.set (key, params.get (key)!) const active = target === resource && params.get ('definition') === definition @@ -187,6 +188,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => { open={open} setOpen={setOpen} reorder={value => update ('order', value)} displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')} + displayRowNumbers={enabled => update ('row_numbers', enabled ? '1' : '0')} execute={next => { if (next.toString () === canonical) void refetch () else setParams (next)