From 304090f71750ccc7e34fdce6cef44e3a33a58230 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 25 Oct 2026 14:14:55 +0900 Subject: [PATCH 01/49] #434 --- .../controllers/concerns/counts_listing.rb | 19 ++ .../controllers/counts_posts_controller.rb | 3 + .../app/controllers/counts_tags_controller.rb | 3 + .../controllers/post_versions_controller.rb | 34 +-- .../controllers/tag_versions_controller.rb | 26 +- backend/app/services/counts_query.rb | 110 ++++++++ backend/config/routes.rb | 3 + backend/spec/requests/counts_spec.rb | 96 +++++++ frontend/server.ssr.test.mjs | 51 +++- frontend/src/App.tsx | 8 + frontend/src/lib/counts.test.ts | 18 ++ frontend/src/lib/counts.ts | 44 +++ frontend/src/lib/queryKeys.ts | 4 + frontend/src/pages/CountsPage.tsx | 17 ++ .../src/pages/CountsResourcePage.test.tsx | 83 ++++++ frontend/src/pages/CountsResourcePage.tsx | 253 ++++++++++++++++++ 16 files changed, 745 insertions(+), 27 deletions(-) create mode 100644 backend/app/controllers/concerns/counts_listing.rb create mode 100644 backend/app/controllers/counts_posts_controller.rb create mode 100644 backend/app/controllers/counts_tags_controller.rb create mode 100644 backend/app/services/counts_query.rb create mode 100644 backend/spec/requests/counts_spec.rb create mode 100644 frontend/src/lib/counts.test.ts create mode 100644 frontend/src/lib/counts.ts create mode 100644 frontend/src/pages/CountsPage.tsx create mode 100644 frontend/src/pages/CountsResourcePage.test.tsx create mode 100644 frontend/src/pages/CountsResourcePage.tsx diff --git a/backend/app/controllers/concerns/counts_listing.rb b/backend/app/controllers/concerns/counts_listing.rb new file mode 100644 index 0000000..80c4660 --- /dev/null +++ b/backend/app/controllers/concerns/counts_listing.rb @@ -0,0 +1,19 @@ +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 = { count: query.count, page: query.page, limit: query.limit } + if query.mode == 'summary' + result.merge!(query.summary) + else + result[:versions] = serialise_versions(query.details(version_scope), locale.language_code) + end + render json: result + rescue ArgumentError, TypeError + head :bad_request + end +end diff --git a/backend/app/controllers/counts_posts_controller.rb b/backend/app/controllers/counts_posts_controller.rb new file mode 100644 index 0000000..74ed700 --- /dev/null +++ b/backend/app/controllers/counts_posts_controller.rb @@ -0,0 +1,3 @@ +class CountsPostsController < PostVersionsController + include CountsListing +end diff --git a/backend/app/controllers/counts_tags_controller.rb b/backend/app/controllers/counts_tags_controller.rb new file mode 100644 index 0000000..2bda72a --- /dev/null +++ b/backend/app/controllers/counts_tags_controller.rb @@ -0,0 +1,3 @@ +class CountsTagsController < TagVersionsController + include CountsListing +end diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index 2617cde..e37bdb5 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -14,21 +14,7 @@ class PostVersionsController < ApplicationController offset = (page - 1) * limit - 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 = version_scope q = q.where('post_versions.post_id = ?', post_id) if post_id if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id))) q = q.where('JSON_CONTAINS(post_versions.tags_json,' + @@ -51,6 +37,24 @@ class PostVersionsController < ApplicationController private + def version_scope + 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') + end + def serialise_versions rows, language_code rows = rows.to_a user_ids = rows.map(&:created_by_user_id).compact.uniq diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index e1169e9..78677a2 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -10,17 +10,7 @@ class TagVersionsController < ApplicationController offset = (page - 1) * limit - 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 = version_scope q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id count = q.except(:select, :order, :limit, :offset).count @@ -34,6 +24,20 @@ class TagVersionsController < ApplicationController 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 + 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 diff --git a/backend/app/services/counts_query.rb b/backend/app/services/counts_query.rb new file mode 100644 index 0000000..1e62d57 --- /dev/null +++ b/backend/app/services/counts_query.rb @@ -0,0 +1,110 @@ +# Read-only history queries. All identifiers and SQL expressions are server-owned. +class CountsQuery + EVENTS = ['create', 'update', 'discard', 'restore'].freeze + AXES = ['event_type', 'month', 'user'].freeze + COMMON_COLUMNS = ['created_at', 'resource_id', 'version_no', 'event_type', 'user'].freeze + COLUMNS = { + 'posts' => COMMON_COLUMNS + ['title', 'url', 'tags', 'added_tags', 'removed_tags'], + 'tags' => COMMON_COLUMNS + ['name', 'category', 'aliases', 'parents', 'deprecated'] }.freeze + KEYS = [ + 'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', + 'user_id', 'event_type', 'resource_id', 'tag', 'columns'].freeze + + attr_reader :page, :limit, :mode, :axis, :columns + + def initialize(scope, resource, params) + @scope = scope.except(:select, :order) + @resource = resource + @table = scope.klass.table_name + @params = params + 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', 'event_type') + 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 = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',') + if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any? + raise ArgumentError + end + apply_filters + end + + def count = @scope.count + + def details(scope) + scope.where(id: @scope.select("#{ @table }.id")) + .order(Arel.sql("#{ @table }.created_at DESC, #{ @table }.id DESC")) + .limit(limit).offset((page - 1) * limit) + end + + def summary + expression = case axis + when 'event_type' then "#{ @table }.event_type" + when 'user' then "#{ @table }.created_by_user_id" + when 'month' then "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" + end + grouped = @scope.group(Arel.sql(expression)) + # A derived table counts groups without materialising every group in Ruby. + sql = grouped.select(Arel.sql("#{ expression } AS group_key")).to_sql + groups = @scope.klass.connection.select_value("SELECT COUNT(*) FROM (#{ sql }) counts_groups") + rows = grouped.order(Arel.sql("#{ expression } ASC")) + .limit(limit).offset((page - 1) * limit).count + names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {} + { group_count: groups.to_i, + groups: rows.map { |key, value| { key:, name: names[key], count: value } } } + end + + private + + 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 + raise ArgumentError unless value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z\z/) + + Date.iso8601(value.first(10)) + Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value) + end + + def apply_filters + first = timestamp('from') + last = timestamp('before') + raise ArgumentError if first && last && first >= last + + @scope = @scope.where("#{ @table }.created_at >= ?", first) if first + @scope = @scope.where("#{ @table }.created_at < ?", last) if last + if @params['resource_id'].present? + id_column = @resource == 'posts' ? 'post_id' : 'tag_id' + @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 + if @params['event_type'].present? + raise ArgumentError unless EVENTS.include?(@params['event_type']) + + @scope = @scope.where(@table => { event_type: @params['event_type'] }) + 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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 01e0895..030a874 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,4 +1,7 @@ Rails.application.routes.draw do + 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 diff --git a/backend/spec/requests/counts_spec.rb b/backend/spec/requests/counts_spec.rb new file mode 100644 index 0000000..bbd3a28 --- /dev/null +++ b/backend/spec/requests/counts_spec.rb @@ -0,0 +1,96 @@ +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 diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs index 668d111..07f1814 100644 --- a/frontend/server.ssr.test.mjs +++ b/frontend/server.ssr.test.mjs @@ -62,6 +62,14 @@ 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: 'create', 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: [] } @@ -300,6 +308,21 @@ 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.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) { @@ -582,7 +605,8 @@ 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)) { + for (const route of routes.filter (entry => !entry.handle?.redirectTo + && (process.env.SSR_COUNTS_ONLY !== '1' || entry.path.startsWith ('counts')))) { 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') @@ -608,6 +632,31 @@ 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') + 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 (process.env.SSR_COUNTS_ONLY === '1') return probeActive = true for (const width of [375, 1240]) { await send ('Emulation.setDeviceMetricsOverride', { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d083062..6e53548 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,6 +77,8 @@ 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')) @@ -194,6 +196,12 @@ export const pageRoutes = ( { id: 'gekanator', path: 'gekanator', element: , handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja' ? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } }, + { path: 'counts', element: , + handle: { preload: CountsPage.preload } }, + { path: 'counts/posts', element: , + handle: { preload: CountsResourcePage.preload } }, + { path: 'counts/tags', element: , + handle: { preload: CountsResourcePage.preload } }, { path: 'more', element: , handle: { preload: MorePage.preload, sitemap: ['static'] } }, { path: '*', element: , handle: { notFound: true } }] diff --git a/frontend/src/lib/counts.test.ts b/frontend/src/lib/counts.test.ts new file mode 100644 index 0000000..c1d14a3 --- /dev/null +++ b/frontend/src/lib/counts.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { columnsForCounts, countsCSV } from '@/lib/counts' + + +describe ('counts exports', () => { + 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') + }) +}) diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts new file mode 100644 index 0000000..da9e9f6 --- /dev/null +++ b/frontend/src/lib/counts.ts @@ -0,0 +1,44 @@ +import { apiGet } from '@/lib/api' + +import type { LocaleCode } from '@/lib/locales' +import type { PostVersion, TagVersion } from '@/types' + +export type CountsResource = 'posts' | 'tags' +export type CountsResult = { + count: number + page: number + limit: number + groupCount?: number + groups?: { key: string | number | null; name: string | null; count: number }[] + versions?: (PostVersion | TagVersion)[] } + +export const countColumns = { + created_at: '変更日時', resource_id: 'ID', version_no: '版番号', + event_type: '操作種別', user: '操作者', title: '投稿タイトル', url: '投稿 URL', + tags: '記載タグ', added_tags: '追加されたタグ', removed_tags: '削除されたタグ', + name: 'タグ名', category: 'カテゴリ', aliases: '別名', parents: '上位タグ', + deprecated: '廃止状態' } + +export type CountColumn = keyof typeof countColumns + + +export const columnsForCounts = (resource: CountsResource): CountColumn[] => [ + 'created_at', 'resource_id', 'version_no', 'event_type', 'user', + ...(resource === 'posts' + ? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const + : ['name', 'category', 'aliases', 'parents', 'deprecated'] as const)] + + +export const fetchCounts = ( + resource: CountsResource, + params: URLSearchParams, + locale: LocaleCode, +): Promise => apiGet (`/counts/${resource}`, { + locale, params: Object.fromEntries (params) }) + + +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') diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 29544b9..9370694 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -161,3 +161,7 @@ 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 = { + index: (resource: string, params: string, locale: LocaleCode) => + ['counts', resource, locale, params] as const } diff --git a/frontend/src/pages/CountsPage.tsx b/frontend/src/pages/CountsPage.tsx new file mode 100644 index 0000000..8dfe9e5 --- /dev/null +++ b/frontend/src/pages/CountsPage.tsx @@ -0,0 +1,17 @@ +import PrefetchLink from '@/components/PrefetchLink' +import PageHead from '@/components/common/PageHead' +import PageTitle from '@/components/common/PageTitle' +import MainArea from '@/components/layout/MainArea' + + +const CountsPage = () => ( + + + 集計・明細照会 +
    +
  • 投稿
  • +
  • タグ
  • +
+
) + +export default CountsPage diff --git a/frontend/src/pages/CountsResourcePage.test.tsx b/frontend/src/pages/CountsResourcePage.test.tsx new file mode 100644 index 0000000..b30d926 --- /dev/null +++ b/frontend/src/pages/CountsResourcePage.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import CountsPage from '@/pages/CountsPage' +import CountsResourcePage from '@/pages/CountsResourcePage' +import { renderWithProviders } from '@/test/render' + +import type { CountsResult } from '@/lib/counts' + + +const { fetchCounts } = vi.hoisted (() => ({ fetchCounts: vi.fn () })) + +vi.mock ('@/lib/counts', async importOriginal => ({ + ...await importOriginal (), fetchCounts })) + + +beforeEach (() => { + fetchCounts.mockReset () + fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 }) +}) + + +describe ('counts pages', () => { + it ('links to the two locale-aware resource screens', () => { + renderWithProviders (, { route: '/ja/counts' }) + expect (screen.getByRole ('link', { name: '投稿' })) + .toHaveAttribute ('href', '/ja/counts/posts') + expect (screen.getByRole ('link', { name: 'タグ' })) + .toHaveAttribute ('href', '/ja/counts/tags') + }) + + it ('restores filters and selected columns and preserves them in page links', async () => { + fetchCounts.mockResolvedValue ({ + count: 21, page: 1, limit: 20, + versions: [{ postId: 7, versionNo: 2, + url: { current: 'https://example.com' }, + title: { current: 'Snapshot' } }] as CountsResult['versions'] }) + renderWithProviders (, { + route: '/ja/counts/posts?mode=details&columns=title&user_id=3' }) + expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument () + expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked () + expect (screen.getByRole ('checkbox', { name: '投稿 URL' })).not.toBeChecked () + expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( + 'href', '/ja/counts/posts?mode=details&columns=title&user_id=3&page=2') + expect (fetchCounts.mock.calls[0][1].get ('user_id')).toBe ('3') + fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' })) + await waitFor (() => { + expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url') + }) + }) + + it ('executes combined filters and resets the page', async () => { + renderWithProviders (, { + route: '/ja/counts/posts?page=3&mode=details' }) + fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), { + target: { value: '7' } }) + fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), { + target: { value: '9' } }) + fireEvent.submit (screen.getByRole ('button', { name: '実行' }).closest ('form')!) + await waitFor (() => { + const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams + expect (params.get ('resource_id')).toBe ('7') + expect (params.get ('user_id')).toBe ('9') + expect (params.has ('page')).toBe (false) + }) + const calls = fetchCounts.mock.calls.length + fireEvent.submit (screen.getByRole ('button', { name: '実行' }).closest ('form')!) + await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (calls)) + }) + + it ('keeps a partial-month date range when drilling down into that month', async () => { + fetchCounts.mockResolvedValue ({ count: 4, groupCount: 1, page: 1, limit: 20, + groups: [{ key: '2024-01', name: null, count: 4 }] }) + renderWithProviders (, { + route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z&event_type=update' }) + const link = await screen.findByRole ('link', { name: '2024-01' }) + const url = new URL (link.getAttribute ('href')!, 'http://localhost') + expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z') + expect (url.searchParams.get ('before')).toBe ('2024-02-01T00:00Z') + expect (url.searchParams.get ('event_type')).toBe ('update') + expect (url.searchParams.get ('mode')).toBe ('details') + }) +}) diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx new file mode 100644 index 0000000..1c93591 --- /dev/null +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -0,0 +1,253 @@ +import { useQuery } from '@tanstack/react-query' +import { useSearchParams } from 'react-router-dom' + +import PrefetchLink from '@/components/PrefetchLink' +import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField' +import FormField from '@/components/common/FormField' +import PageHead from '@/components/common/PageHead' +import PageTitle from '@/components/common/PageTitle' +import Pagination from '@/components/common/Pagination' +import MainArea from '@/components/layout/MainArea' +import { Button } from '@/components/ui/button' +import { columnsForCounts, countColumns, countsCSV, fetchCounts } from '@/lib/counts' +import { countsKeys } from '@/lib/queryKeys' +import { inputClass, localisedDateString } from '@/lib/utils' +import { useLocale } from '@/lib/useLocale' +import { useUITranslation } from '@/lib/useUITranslation' + +import type { CountColumn, CountsResource } from '@/lib/counts' +import type { PostVersion, TagVersion } from '@/types' + + +const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { + const [params, setParams] = useSearchParams () + const locale = useLocale () + const { label, message } = useUITranslation ('history') + const mode = params.get ('mode') ?? 'summary' + const axis = params.get ('axis') ?? 'event_type' + const availableColumns = columnsForCounts (resource) + const selectedColumns = params.has ('columns') + ? params.get ('columns')!.split (',') as CountColumn[] + : availableColumns + const columns = selectedColumns.filter (column => availableColumns.includes (column)) + const title = `集計・明細照会:${resource === 'posts' ? '投稿' : 'タグ'}` + const { data, isPending, isError, refetch } = useQuery ({ + queryKey: countsKeys.index (resource, params.toString (), locale), + queryFn: () => fetchCounts (resource, params, locale), retry: false }) + + const update = (key: string, value: string) => { + const next = new URLSearchParams (params) + if (value) + next.set (key, value) + else + next.delete (key) + next.delete ('page') + setParams (next) + } + + const eventName = (value: string) => value + const cell = (row: PostVersion | TagVersion, column: CountColumn): string => { + switch (column) + { + case 'created_at': return localisedDateString (row.createdAt, locale) + case 'resource_id': return String ('postId' in row ? row.postId : row.tagId) + case 'version_no': return String (row.versionNo) + case 'event_type': return eventName (row.eventType) + case 'user': return row.createdByUser?.name ?? '—' + } + if ('postId' in row) + { + if (column === 'title') return row.title.current ?? '' + if (column === 'url') return row.url.current + const tags = row.tags.filter (tag => column === 'added_tags' + ? tag.type === 'added' + : (column === 'removed_tags' ? tag.type === 'removed' : tag.type !== 'removed')) + return tags.map (tag => tag.name).join (' ') + } + switch (column) + { + case 'name': return row.name.current ?? '' + case 'category': return message (`common:categories.${row.category.current}`).text + case 'aliases': + return row.aliases.filter (item => item.type !== 'removed') + .map (item => item.name).join (' ') + case 'parents': + return row.parentTags.filter (item => item.type !== 'removed') + .map (item => item.tag.name).join (' ') + case 'deprecated': return row.deprecatedAt.current ?? '—' + default: return '' + } + } + const groups = data?.groups ?? [] + const versions = data?.versions ?? [] + const groupName = (group: typeof groups[number]) => axis === 'event_type' + ? eventName (String (group.key)) + : (axis === 'user' ? group.name ?? String (group.key ?? '—') : String (group.key)) + const drillDown = (key: string | number | null): string => { + const next = new URLSearchParams (params) + next.set ('mode', 'details') + next.delete ('page') + if (axis === 'month') + { + const first = `${key}-01T00:00Z` + const date = new Date (first) + date.setUTCMonth (date.getUTCMonth () + 1) + const last = date.toISOString ().slice (0, 16) + 'Z' + const from = next.get ('from') + const before = next.get ('before') + next.set ('from', from != null && from > first ? from : first) + next.set ('before', before != null && before < last ? before : last) + } + else + next.set (axis === 'user' ? 'user_id' : 'event_type', String (key ?? 'none')) + return `?${next}` + } + const exportCSV = () => { + const rows = mode === 'summary' + ? [['集計軸', '件数'], ...groups.map (group => [groupName (group), String (group.count)])] + : [columns.map (column => countColumns[column]), + ...versions.map (row => columns.map (column => cell (row, column)))] + const url = URL.createObjectURL (new Blob ([countsCSV (rows)], { + type: 'text/csv;charset=utf-8' })) + const link = document.createElement ('a') + link.href = url + link.download = `counts-${resource}.csv` + link.click () + URL.revokeObjectURL (url) + } + + return ( + + + {title} + 集計・明細照会 +
{ + event.preventDefault () + const form = new FormData (event.currentTarget) + const next = new URLSearchParams (params) + for (const key of [ + 'resource_id', 'user_id', 'event_type', 'tag', 'limit', 'from', 'before']) + { + const raw = String (form.get (key) ?? '') + const value = (raw && ['from', 'before'].includes (key)) + ? toMinutePrecisionIsoUtc (raw) : raw + if (value) next.set (key, value) + else next.delete (key) + } + next.delete ('page') + if (next.toString () === params.toString ()) + void refetch () + else + setParams (next) + }}> +
+ + {() => } + + + {() => } + + + {() => } + + {[['resource_id', resource === 'posts' ? '投稿 ID' : 'タグ ID'], + ['user_id', '操作者 ID'], + ...(resource === 'posts' ? [['tag', '記載・変更タグ ID']] : [])] + .map (([key, name]) => ( + + {() => } + ))} + + {() => } + + + {() => } + +
+ {mode === 'summary' ? ( + + {() => } + ) : ( +
+ 出力項目 + {availableColumns.map (column => ( + ))} +
)} +
+ + +
+
+

変更日時:開始以上・終了未満。入力は端末の時刻帯、月別は UTC。

+

件数は履歴レコード数です。

+ {isPending &&

{label ('shared:messages.loadingEllipsis')}

} + {isError &&

{label ('common:fetchError')}

} + {data != null && <> +

総件数:{data.count}

+
+ + + {mode === 'summary' + ? <> + : <> + {columns.map (column => )}} + + + {mode === 'summary' ? groups.map (group => ( + + + + )) : versions.map (row => { + const id = 'postId' in row ? row.postId : row.tagId + return + + {columns.map (column => )} + + })} + +
集計軸件数{resource === 'posts' ? '投稿' : 'タグ'} + {countColumns[column]}
+ {groupName (group)}{group.count}
#{id} + {column === 'url' + ? + {cell (row, column)} + : cell (row, column)}
+
+ + } +
) +} + + +export default CountsResourcePage From d411b3b40fc9d7c75e3d8d215eca1b0d75e69d0d Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 25 Oct 2026 14:53:35 +0900 Subject: [PATCH 02/49] #434 --- AGENTS.md | 13 + frontend/AGENTS.md | 15 +- frontend/server.ssr.test.mjs | 41 +- frontend/src/lib/counts.test.ts | 25 +- frontend/src/lib/counts.ts | 36 +- frontend/src/locales/ain-Kana/counts.json | 3 + frontend/src/locales/ja/counts.json | 42 ++ frontend/src/pages/CountsPage.tsx | 15 +- .../src/pages/CountsResourcePage.test.tsx | 138 ++++-- frontend/src/pages/CountsResourcePage.tsx | 418 +++++++++++------- 10 files changed, 528 insertions(+), 218 deletions(-) create mode 100644 frontend/src/locales/ain-Kana/counts.json create mode 100644 frontend/src/locales/ja/counts.json diff --git a/AGENTS.md b/AGENTS.md index 1b27081..6b17df2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -631,6 +631,19 @@ and layout reuse, follow `frontend/AGENTS.md`. ## Frontend rules +- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。 + 新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。 + 不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。 +- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と + `_untranslated` 規約を使い、language metadata を維持する。 + 翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。 +- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の + 確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。 + この規約はテストコードの追加・修正を義務付けない。 +- 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. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index ae68412..a7693dc 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -128,6 +128,19 @@ pass or the remaining failure is clearly blocked. ## Tailwind and UI +- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。 + 新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。 + 不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。 +- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と + `_untranslated` 規約を使い、language metadata を維持する。 + 翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。 +- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の + 確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。 + この規約はテストコードの追加・修正を義務付けない。 +- 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` @@ -144,7 +157,7 @@ pass or the remaining failure is clearly blocked. 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 Japanese labels that fit the control. + short localised 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, diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs index 07f1814..98a62b6 100644 --- a/frontend/server.ssr.test.mjs +++ b/frontend/server.ssr.test.mjs @@ -65,7 +65,8 @@ const responseFor = url => { 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: 'create', name: null, 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}` } }] } @@ -317,6 +318,10 @@ test ('counts SSR renders history data and retains URL query state', async () => 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}`))) } @@ -648,10 +653,44 @@ test ('hydrate every route with stored browser preferences', { 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')) + } } }) } diff --git a/frontend/src/lib/counts.test.ts b/frontend/src/lib/counts.test.ts index c1d14a3..ea045c0 100644 --- a/frontend/src/lib/counts.test.ts +++ b/frontend/src/lib/counts.test.ts @@ -1,9 +1,30 @@ import { describe, expect, it } from 'vitest' -import { columnsForCounts, countsCSV } from '@/lib/counts' +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"') @@ -14,5 +35,7 @@ describe ('counts exports', () => { 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') }) }) diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts index da9e9f6..f6e2730 100644 --- a/frontend/src/lib/counts.ts +++ b/frontend/src/lib/counts.ts @@ -13,22 +13,46 @@ export type CountsResult = { versions?: (PostVersion | TagVersion)[] } export const countColumns = { - created_at: '変更日時', resource_id: 'ID', version_no: '版番号', - event_type: '操作種別', user: '操作者', title: '投稿タイトル', url: '投稿 URL', - tags: '記載タグ', added_tags: '追加されたタグ', removed_tags: '削除されたタグ', - name: 'タグ名', category: 'カテゴリ', aliases: '別名', parents: '上位タグ', - deprecated: '廃止状態' } + created_at: 'counts:columns.changedAt', resource_id: 'counts:columns.resourceId', + version_no: 'history:version', user: 'history:actor', + title: 'counts:columns.postTitle', url: 'counts:columns.postURL', + tags: 'counts:columns.tags', added_tags: 'counts:columns.addedTags', + removed_tags: 'counts:columns.removedTags', name: 'history:tagName', + category: 'common:categoryLabel', aliases: 'tags:aliases', parents: 'tags:parents', + deprecated: 'counts:columns.deprecated' } export type CountColumn = keyof typeof countColumns export const columnsForCounts = (resource: CountsResource): CountColumn[] => [ - 'created_at', 'resource_id', 'version_no', 'event_type', 'user', + 'created_at', 'resource_id', 'version_no', 'user', ...(resource === 'posts' ? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const : ['name', 'category', 'aliases', 'parents', 'deprecated'] as const)] +export const normaliseCountsParams = ( + resource: CountsResource, + source: URLSearchParams, +): URLSearchParams => { + const params = new URLSearchParams (source) + params.delete ('event_type') + if (!['month', 'user'].includes (params.get ('axis') ?? '')) + params.set ('axis', 'month') + if (params.has ('columns')) + { + const available = columnsForCounts (resource) + const columns = params.get ('columns')!.split (',') + .filter (column => available.includes (column as CountColumn)) + if (columns.length > 0) + params.set ('columns', [...new Set (columns)].join (',')) + else + params.delete ('columns') + } + return params +} + + export const fetchCounts = ( resource: CountsResource, params: URLSearchParams, diff --git a/frontend/src/locales/ain-Kana/counts.json b/frontend/src/locales/ain-Kana/counts.json new file mode 100644 index 0000000..ef28b0c --- /dev/null +++ b/frontend/src/locales/ain-Kana/counts.json @@ -0,0 +1,3 @@ +{ + "_untranslated": ["*"] +} diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json new file mode 100644 index 0000000..97e005b --- /dev/null +++ b/frontend/src/locales/ja/counts.json @@ -0,0 +1,42 @@ +{ + "titles": { + "posts": "投稿の集計・明細", + "tags": "タグの集計・明細" + }, + "target": "集計対象", + "run": "集計実行", + "conditions": "集計条件", + "output": "出力項目", + "filters": "抽出条件", + "mode": "表示", + "summary": "集計", + "details": "明細", + "axis": "集計軸", + "axes": { + "month": "月別(UTC)", + "user": "$t(history:actor)" + }, + "metric": "履歴レコード件数", + "total": "履歴レコード件数:{{count}}", + "csv": "CSV", + "csvScope": "表示中のページ・選択列を CSV 出力", + "empty": "該当する履歴はありません。", + "columns": { + "changedAt": "変更日時", + "resourceId": "ID", + "postTitle": "投稿タイトル", + "postURL": "投稿 URL", + "tags": "記載タグ", + "addedTags": "追加されたタグ", + "removedTags": "削除されたタグ", + "deprecated": "廃止状態" + }, + "fields": { + "from": "開始(端末の時刻帯・以上)", + "before": "終了(端末の時刻帯・未満)", + "post": "投稿 ID", + "tag": "タグ名または ID", + "actor": "操作者 ID", + "limit": "表示件数" + } +} diff --git a/frontend/src/pages/CountsPage.tsx b/frontend/src/pages/CountsPage.tsx index 8dfe9e5..6be614d 100644 --- a/frontend/src/pages/CountsPage.tsx +++ b/frontend/src/pages/CountsPage.tsx @@ -1,17 +1,6 @@ -import PrefetchLink from '@/components/PrefetchLink' -import PageHead from '@/components/common/PageHead' -import PageTitle from '@/components/common/PageTitle' -import MainArea from '@/components/layout/MainArea' +import CountsResourcePage from '@/pages/CountsResourcePage' -const CountsPage = () => ( - - - 集計・明細照会 -
    -
  • 投稿
  • -
  • タグ
  • -
-
) +const CountsPage = () => export default CountsPage diff --git a/frontend/src/pages/CountsResourcePage.test.tsx b/frontend/src/pages/CountsResourcePage.test.tsx index b30d926..5400e43 100644 --- a/frontend/src/pages/CountsResourcePage.test.tsx +++ b/frontend/src/pages/CountsResourcePage.test.tsx @@ -7,77 +7,151 @@ import { renderWithProviders } from '@/test/render' import type { CountsResult } from '@/lib/counts' - -const { fetchCounts } = vi.hoisted (() => ({ fetchCounts: vi.fn () })) +const { fetchCounts, fetchTagByName } = vi.hoisted (() => ({ + fetchCounts: vi.fn (), fetchTagByName: vi.fn () })) vi.mock ('@/lib/counts', async importOriginal => ({ ...await importOriginal (), fetchCounts })) +vi.mock ('@/lib/tags', async importOriginal => ({ + ...await importOriginal (), fetchTagByName })) beforeEach (() => { fetchCounts.mockReset () + fetchTagByName.mockReset () fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 }) }) -describe ('counts pages', () => { - it ('links to the two locale-aware resource screens', () => { +const conditions = () => screen.getByRole ('button', { name: '集計条件' }) + + +const execute = () => fireEvent.submit ( + screen.getByRole ('button', { name: '集計実行' }).closest ('form')!) + + +describe ('counts workspace', () => { + it ('opens the posts workspace at the root with a resource sidebar', async () => { renderWithProviders (, { route: '/ja/counts' }) + fireEvent.click (screen.getByRole ('button', { name: '集計対象' })) expect (screen.getByRole ('link', { name: '投稿' })) .toHaveAttribute ('href', '/ja/counts/posts') + expect (screen.getByRole ('link', { name: '投稿' })) + .toHaveAttribute ('aria-current', 'page') expect (screen.getByRole ('link', { name: 'タグ' })) .toHaveAttribute ('href', '/ja/counts/tags') + expect (conditions ()).toHaveAttribute ('aria-expanded', 'false') + await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('axis')).toBe ('month')) }) - it ('restores filters and selected columns and preserves them in page links', async () => { - fetchCounts.mockResolvedValue ({ - count: 21, page: 1, limit: 20, - versions: [{ postId: 7, versionNo: 2, - url: { current: 'https://example.com' }, - title: { current: 'Snapshot' } }] as CountsResult['versions'] }) - renderWithProviders (, { - route: '/ja/counts/posts?mode=details&columns=title&user_id=3' }) - expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument () - expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked () - expect (screen.getByRole ('checkbox', { name: '投稿 URL' })).not.toBeChecked () - expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( - 'href', '/ja/counts/posts?mode=details&columns=title&user_id=3&page=2') - expect (fetchCounts.mock.calls[0][1].get ('user_id')).toBe ('3') - fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' })) - await waitFor (() => { - expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url') - }) - }) - - it ('executes combined filters and resets the page', async () => { + it ('preserves draft filters on collapse without querying until execution', async () => { renderWithProviders (, { route: '/ja/counts/posts?page=3&mode=details' }) + await screen.findByText ('履歴レコード件数:0') + const calls = fetchCounts.mock.calls.length + fireEvent.click (conditions ()) + const output = document.querySelector ('[data-counts-output]')! + const filters = document.querySelector ('[data-counts-filters]')! + expect (output.compareDocumentPosition (filters) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy () fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), { target: { value: '7' } }) fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), { target: { value: '9' } }) - fireEvent.submit (screen.getByRole ('button', { name: '実行' }).closest ('form')!) + fireEvent.click (conditions ()) + expect (screen.getByRole ('table')).toBeInTheDocument () + fireEvent.click (conditions ()) + expect (screen.getByRole ('textbox', { name: '投稿 ID' })).toHaveValue ('7') + expect (fetchCounts).toHaveBeenCalledTimes (calls) + execute () await waitFor (() => { const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams expect (params.get ('resource_id')).toBe ('7') expect (params.get ('user_id')).toBe ('9') expect (params.has ('page')).toBe (false) }) - const calls = fetchCounts.mock.calls.length - fireEvent.submit (screen.getByRole ('button', { name: '実行' }).closest ('form')!) - await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (calls)) + const refreshed = fetchCounts.mock.calls.length + execute () + await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (refreshed)) }) - it ('keeps a partial-month date range when drilling down into that month', async () => { + it ('applies selected columns only on execution and retains pagination state', async () => { + fetchCounts.mockResolvedValue ({ count: 21, page: 1, limit: 20, + versions: [{ postId: 7, versionNo: 2, url: { current: 'https://example.com' }, + title: { current: 'Snapshot' } }] as CountsResult['versions'] }) + renderWithProviders (, { + route: '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3' }) + expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument () + expect (screen.getByLabelText ('次のページ')).toHaveAttribute ( + 'href', '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3&page=2') + fireEvent.click (conditions ()) + expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked () + fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' })) + expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title') + execute () + await waitFor (() => { + expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url') + }) + }) + + it ('removes legacy event fields and axes from public query state', async () => { + renderWithProviders (, { + route: '/ja/counts/tags?axis=event_type&event_type=update&columns=event_type,name' }) + await waitFor (() => { + const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams + expect (params.get ('axis')).toBe ('month') + expect (params.has ('event_type')).toBe (false) + expect (params.get ('columns')).toBe ('name') + }) + fireEvent.click (conditions ()) + expect (screen.queryByText ('操作種別')).not.toBeInTheDocument () + expect (screen.getByRole ('combobox', { name: '集計軸' })).toHaveValue ('month') + }) + + it ('resolves a tag name with the existing lookup before running the query', async () => { + fetchTagByName.mockResolvedValue ({ id: 12 }) + renderWithProviders (, { + route: '/ja/counts/posts' }) + fireEvent.click (conditions ()) + fireEvent.change (screen.getByRole ('textbox', { name: 'タグ名または ID' }), { + target: { value: '日本語タグ' } }) + execute () + await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('tag')).toBe ('12')) + expect (fetchTagByName).toHaveBeenCalledWith ('日本語タグ', 'ja') + }) + + it ('uses Japanese fallback metadata in the Ainu workspace without exposing keys', async () => { + const { container } = renderWithProviders (, { + route: '/ain-Kana/counts/tags' }) + const title = screen.getByRole ('heading', { name: 'タグの集計・明細' }) + expect (title.querySelector ('[lang="ja"]')).toHaveTextContent ('タグの集計・明細') + expect (conditions ().querySelector ('[lang="ja"]')).toBeInTheDocument () + fireEvent.click (conditions ()) + await screen.findByRole ('table') + expect (container.textContent).not.toMatch (/counts:|columns\.|fields\.|axes\./) + }) + + it ('drills through an actor group while preserving the existing filters', async () => { + fetchCounts.mockResolvedValue ({ count: 3, groupCount: 1, page: 1, limit: 20, + groups: [{ key: 9, name: 'Actor name', count: 3 }] }) + renderWithProviders (, { + route: '/ja/counts/posts?axis=user&resource_id=7' }) + const link = await screen.findByRole ('link', { name: 'Actor name' }) + const url = new URL (link.getAttribute ('href')!, 'http://localhost') + expect (url.searchParams.get ('resource_id')).toBe ('7') + expect (url.searchParams.get ('user_id')).toBe ('9') + expect (url.searchParams.get ('mode')).toBe ('details') + }) + + it ('keeps partial-month boundaries when drilling through to details', async () => { fetchCounts.mockResolvedValue ({ count: 4, groupCount: 1, page: 1, limit: 20, groups: [{ key: '2024-01', name: null, count: 4 }] }) renderWithProviders (, { - route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z&event_type=update' }) + route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z' }) const link = await screen.findByRole ('link', { name: '2024-01' }) const url = new URL (link.getAttribute ('href')!, 'http://localhost') expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z') expect (url.searchParams.get ('before')).toBe ('2024-02-01T00:00Z') - expect (url.searchParams.get ('event_type')).toBe ('update') expect (url.searchParams.get ('mode')).toBe ('details') }) }) diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index 1c93591..07949e5 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -1,17 +1,22 @@ import { useQuery } from '@tanstack/react-query' +import { useEffect, useId, useState } from 'react' import { useSearchParams } from 'react-router-dom' +import LocalisedText from '@/components/LocalisedText' import PrefetchLink from '@/components/PrefetchLink' -import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField' +import DateTimeField from '@/components/common/DateTimeField' import FormField from '@/components/common/FormField' import PageHead from '@/components/common/PageHead' import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' +import SidebarComponent from '@/components/layout/SidebarComponent' import { Button } from '@/components/ui/button' -import { columnsForCounts, countColumns, countsCSV, fetchCounts } from '@/lib/counts' +import { columnsForCounts, countColumns, countsCSV, + fetchCounts, normaliseCountsParams } from '@/lib/counts' import { countsKeys } from '@/lib/queryKeys' -import { inputClass, localisedDateString } from '@/lib/utils' +import { fetchTagByName } from '@/lib/tags' +import { cn, inputClass, localisedDateString } from '@/lib/utils' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' @@ -19,41 +24,169 @@ import type { CountColumn, CountsResource } from '@/lib/counts' import type { PostVersion, TagVersion } from '@/types' -const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { - const [params, setParams] = useSearchParams () +const CountsControls = ( + { resource, applied, open, setOpen, execute }: { + resource: CountsResource + applied: URLSearchParams + open: boolean + setOpen: (open: boolean) => void + execute: (params: URLSearchParams) => void }, +) => { + const { label, message } = useUITranslation ('counts') const locale = useLocale () - const { label, message } = useUITranslation ('history') - const mode = params.get ('mode') ?? 'summary' - const axis = params.get ('axis') ?? 'event_type' - const availableColumns = columnsForCounts (resource) - const selectedColumns = params.has ('columns') - ? params.get ('columns')!.split (',') as CountColumn[] - : availableColumns - const columns = selectedColumns.filter (column => availableColumns.includes (column)) - const title = `集計・明細照会:${resource === 'posts' ? '投稿' : 'タグ'}` - const { data, isPending, isError, refetch } = useQuery ({ - queryKey: countsKeys.index (resource, params.toString (), locale), - queryFn: () => fetchCounts (resource, params, locale), retry: false }) - + const id = useId () + const [draft, setDraft] = useState (() => new URLSearchParams (applied)) + const [error, setError] = useState (false) + const [pending, setPending] = useState (false) + const available = columnsForCounts (resource) + const columns = (draft.get ('columns')?.split (',') ?? available) as CountColumn[] + const mode = draft.get ('mode') ?? 'summary' const update = (key: string, value: string) => { - const next = new URLSearchParams (params) - if (value) - next.set (key, value) - else - next.delete (key) - next.delete ('page') - setParams (next) + setDraft (previous => { + const next = new URLSearchParams (previous) + if (value) next.set (key, value) + else next.delete (key) + return next + }) + } + const submit = async () => { + setError (false) + setPending (true) + try + { + const next = new URLSearchParams (draft) + for (const key of resource === 'posts' ? ['tag'] : ['resource_id']) + { + const value = next.get (key)?.trim () ?? '' + if (value && !/^[1-9][0-9]*$/.test (value)) + { + const tag = await fetchTagByName (value, locale) + if (tag == null) + throw new Error () + next.set (key, String (tag.id)) + } + } + next.delete ('page') + execute (normaliseCountsParams (resource, next)) + } + catch + { + setError (true) + } + finally + { + setPending (false) + } } - const eventName = (value: string) => value + return ( +
{ + event.preventDefault () + void submit () + }}> +
+ + + +
+ {error &&

{label ('shared:messages.checkInputModern')}

} + +
) +} + + +const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { + const [source, setParams] = useSearchParams () + const params = normaliseCountsParams (resource, source) + const canonical = params.toString () + const [open, setOpen] = useState (false) + const [sidebarOpen, setSidebarOpen] = useState (false) + const locale = useLocale () + const { label, message } = useUITranslation ('counts') + const mode = params.get ('mode') ?? 'summary' + const axis = params.get ('axis') ?? 'month' + const columns = (params.get ('columns')?.split (',') + ?? columnsForCounts (resource)) as CountColumn[] + const { data, isPending, isError, refetch } = useQuery ({ + queryKey: countsKeys.index (resource, canonical, locale), + queryFn: () => fetchCounts (resource, params, locale), retry: false }) + + useEffect (() => { + if (source.toString () !== canonical) + setParams (canonical, { replace: true }) + }, [canonical, source, setParams]) + const cell = (row: PostVersion | TagVersion, column: CountColumn): string => { switch (column) { case 'created_at': return localisedDateString (row.createdAt, locale) case 'resource_id': return String ('postId' in row ? row.postId : row.tagId) case 'version_no': return String (row.versionNo) - case 'event_type': return eventName (row.eventType) - case 'user': return row.createdByUser?.name ?? '—' + case 'user': return row.createdByUser?.name ?? '' } if ('postId' in row) { @@ -74,15 +207,14 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { case 'parents': return row.parentTags.filter (item => item.type !== 'removed') .map (item => item.tag.name).join (' ') - case 'deprecated': return row.deprecatedAt.current ?? '—' + case 'deprecated': return row.deprecatedAt.current ?? '' default: return '' } } const groups = data?.groups ?? [] const versions = data?.versions ?? [] - const groupName = (group: typeof groups[number]) => axis === 'event_type' - ? eventName (String (group.key)) - : (axis === 'user' ? group.name ?? String (group.key ?? '—') : String (group.key)) + const groupName = (group: typeof groups[number]) => + axis === 'user' ? group.name ?? String (group.key ?? '') : String (group.key) const drillDown = (key: string | number | null): string => { const next = new URLSearchParams (params) next.set ('mode', 'details') @@ -99,13 +231,16 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { next.set ('before', before != null && before < last ? before : last) } else - next.set (axis === 'user' ? 'user_id' : 'event_type', String (key ?? 'none')) + next.set ('user_id', String (key ?? 'none')) return `?${next}` } + const groupText = (group: typeof groups[number]) => + group.key == null ? message ('history:bot').text : groupName (group) const exportCSV = () => { const rows = mode === 'summary' - ? [['集計軸', '件数'], ...groups.map (group => [groupName (group), String (group.count)])] - : [columns.map (column => countColumns[column]), + ? [[message (`axes.${axis}`).text, message ('metric').text], + ...groups.map (group => [groupText (group), String (group.count)])] + : [columns.map (column => message (countColumns[column]).text), ...versions.map (row => columns.map (column => cell (row, column)))] const url = URL.createObjectURL (new Blob ([countsCSV (rows)], { type: 'text/csv;charset=utf-8' })) @@ -117,136 +252,91 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { } return ( - - - {title} - 集計・明細照会 -
{ - event.preventDefault () - const form = new FormData (event.currentTarget) - const next = new URLSearchParams (params) - for (const key of [ - 'resource_id', 'user_id', 'event_type', 'tag', 'limit', 'from', 'before']) - { - const raw = String (form.get (key) ?? '') - const value = (raw && ['from', 'before'].includes (key)) - ? toMinutePrecisionIsoUtc (raw) : raw - if (value) next.set (key, value) - else next.delete (key) - } - next.delete ('page') - if (next.toString () === params.toString ()) - void refetch () - else - setParams (next) - }}> -
- - {() => } - - - {() => } - - - {() => } - - {[['resource_id', resource === 'posts' ? '投稿 ID' : 'タグ ID'], - ['user_id', '操作者 ID'], - ...(resource === 'posts' ? [['tag', '記載・変更タグ ID']] : [])] - .map (([key, name]) => ( - - {() => } - ))} - - {() => } - - - {() => } - +
+ + + + + + + {label (`titles.${resource}`)} +
+ { + if (next.toString () === canonical) void refetch () + else setParams (next) + }}/> +
- {mode === 'summary' ? ( - - {() => } - ) : ( -
- 出力項目 - {availableColumns.map (column => ( - ))} -
)} -
- - -
- -

変更日時:開始以上・終了未満。入力は端末の時刻帯、月別は UTC。

-

件数は履歴レコード数です。

- {isPending &&

{label ('shared:messages.loadingEllipsis')}

} - {isError &&

{label ('common:fetchError')}

} - {data != null && <> -

総件数:{data.count}

-
- - - {mode === 'summary' - ? <> - : <> - {columns.map (column => )}} - - - {mode === 'summary' ? groups.map (group => ( - - - - )) : versions.map (row => { - const id = 'postId' in row ? row.postId : row.tagId - return - - {columns.map (column => )} - - })} - -
集計軸件数{resource === 'posts' ? '投稿' : 'タグ'} - {countColumns[column]}
- {groupName (group)}{group.count}
#{id} - {column === 'url' - ? - {cell (row, column)} - : cell (row, column)}
-
- - } -
) + {isPending &&

{label ('shared:messages.loadingEllipsis')}

} + {isError &&

{label ('common:fetchError')}

} + {data != null && <> +

{label ('total', { count: data.count })}

+
+ + + {mode === 'summary' + ? <> + + : <> + {columns.map (column => ( + ))}} + + + {mode === 'summary' ? groups.map (group => ( + + + + )) : versions.map (row => { + const id = 'postId' in row ? row.postId : row.tagId + return + + {columns.map (column => ( + ))} + + })} + +
{label (`axes.${axis}`)}{label ('metric')} + {label (resource === 'posts' ? 'history:post' : 'navigation:tags')} + {label (countColumns[column])}
+ {group.key == null ? label ('history:bot') : groupName (group)} + {group.count}
#{id} + {column === 'url' + ? + {cell (row, column)} + : (column === 'category' && 'tagId' in row + ? + {cell (row, column)} + : cell (row, column))}
+ {(mode === 'summary' ? groups.length : versions.length) === 0 + &&

{label ('empty')}

} +
+ + } + +
) } From 3e05757820cecbd76a56b41f2b525dba913d88e3 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 25 Oct 2026 16:00:37 +0900 Subject: [PATCH 03/49] #434 --- AGENTS.md | 14 +- .../controllers/concerns/counts_listing.rb | 3 +- .../representations/counts_version_repr.rb | 97 +++++ backend/app/services/counts_query.rb | 141 +++++-- frontend/AGENTS.md | 12 +- .../src/components/counts/CountsControls.tsx | 145 +++++++ .../src/components/counts/CountsFieldTree.tsx | 72 ++++ .../components/counts/CountsNestedTable.tsx | 101 +++++ .../components/counts/CountsResizeHandle.tsx | 93 +++++ .../src/components/counts/CountsResults.tsx | 149 +++++++ .../counts/CountsSelectableTable.tsx | 222 +++++++++++ .../components/counts/CountsSortHeader.tsx | 96 +++++ .../components/counts/CountsTableLayout.tsx | 24 ++ frontend/src/lib/counts.ts | 220 +++++++++-- frontend/src/lib/countsDisplay.ts | 190 +++++++++ frontend/src/lib/countsTableLayout.ts | 28 ++ frontend/src/lib/utils.ts | 52 +-- frontend/src/locales/ja/counts.json | 51 ++- frontend/src/pages/CountsResourcePage.tsx | 370 ++++-------------- 19 files changed, 1692 insertions(+), 388 deletions(-) create mode 100644 backend/app/representations/counts_version_repr.rb create mode 100644 frontend/src/components/counts/CountsControls.tsx create mode 100644 frontend/src/components/counts/CountsFieldTree.tsx create mode 100644 frontend/src/components/counts/CountsNestedTable.tsx create mode 100644 frontend/src/components/counts/CountsResizeHandle.tsx create mode 100644 frontend/src/components/counts/CountsResults.tsx create mode 100644 frontend/src/components/counts/CountsSelectableTable.tsx create mode 100644 frontend/src/components/counts/CountsSortHeader.tsx create mode 100644 frontend/src/components/counts/CountsTableLayout.tsx create mode 100644 frontend/src/lib/countsDisplay.ts create mode 100644 frontend/src/lib/countsTableLayout.ts diff --git a/AGENTS.md b/AGENTS.md index 6b17df2..9d61b3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -573,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. -- 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. +- 明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。 + fixture、mock、snapshot、テスト補助コードも対象とする。 + 既存のテスト差分を勝手に修正・差戻ししない。 + この規約はディレクトリ固有の自動的なテスト実行推奨より優先する。 - 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 @@ -637,9 +637,9 @@ and layout reuse, follow `frontend/AGENTS.md`. - 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と `_untranslated` 規約を使い、language metadata を維持する。 翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。 -- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の - 確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。 - この規約はテストコードの追加・修正を義務付けない。 +- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と + SSR / hydration の整合及び翻訳キーの非露出を検証する。 + i18n の検証を理由にテスト関連作業を開始しない。 - API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に 翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、 アクセシビリティ用ラベルも UI 文言として扱う。 diff --git a/backend/app/controllers/concerns/counts_listing.rb b/backend/app/controllers/concerns/counts_listing.rb index 80c4660..a1a4769 100644 --- a/backend/app/controllers/concerns/counts_listing.rb +++ b/backend/app/controllers/concerns/counts_listing.rb @@ -10,7 +10,8 @@ module CountsListing if query.mode == 'summary' result.merge!(query.summary) else - result[:versions] = serialise_versions(query.details(version_scope), locale.language_code) + result[:versions] = CountsVersionRepr.many( + query.details, resource:, language_code: locale.language_code) end render json: result rescue ArgumentError, TypeError diff --git a/backend/app/representations/counts_version_repr.rb b/backend/app/representations/counts_version_repr.rb new file mode 100644 index 0000000..e3a7c5b --- /dev/null +++ b/backend/app/representations/counts_version_repr.rb @@ -0,0 +1,97 @@ +# Counts uses recorded values only; history/diff endpoints retain their own contract. +class CountsVersionRepr + def self.many(rows, resource:, language_code:) + new(rows.to_a, resource:, language_code:).serialise + end + + def initialize(rows, resource:, language_code:) + @rows = rows + @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' + 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(', ') + TagVersion.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 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 { { id: _1.to_i } }, + deprecated: row.deprecated_at&.iso8601 } + end +end diff --git a/backend/app/services/counts_query.rb b/backend/app/services/counts_query.rb index 1e62d57..5dde9b1 100644 --- a/backend/app/services/counts_query.rb +++ b/backend/app/services/counts_query.rb @@ -1,59 +1,71 @@ -# Read-only history queries. All identifiers and SQL expressions are server-owned. +# Read-only history queries. SQL identifiers and expressions are server-owned. class CountsQuery - EVENTS = ['create', 'update', 'discard', 'restore'].freeze - AXES = ['event_type', 'month', 'user'].freeze - COMMON_COLUMNS = ['created_at', 'resource_id', 'version_no', 'event_type', 'user'].freeze + AXES = ['month', 'user'].freeze + COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'user', 'created_at'].freeze COLUMNS = { - 'posts' => COMMON_COLUMNS + ['title', 'url', 'tags', 'added_tags', 'removed_tags'], - 'tags' => COMMON_COLUMNS + ['name', 'category', 'aliases', 'parents', 'deprecated'] }.freeze + 'posts' => COMMON_COLUMNS + [ + 'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms', + 'original_created_at', 'original_created_from', 'original_created_before'], + 'tags' => COMMON_COLUMNS + [ + 'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze KEYS = [ - 'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', - 'user_id', 'event_type', 'resource_id', 'tag', 'columns'].freeze + 'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest', + 'user_id', 'resource_id', 'tag', 'columns', 'sort'].freeze - attr_reader :page, :limit, :mode, :axis, :columns + attr_reader :page, :limit, :mode, :axis def initialize(scope, resource, params) - @scope = scope.except(:select, :order) - @resource = resource @table = scope.klass.table_name + @resource = resource + @id_column = resource == 'posts' ? 'post_id' : 'tag_id' @params = params + @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', 'event_type') + @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 = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',') + columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',') if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any? raise ArgumentError end + @sort = parse_sort apply_filters end def count = @scope.count - def details(scope) - scope.where(id: @scope.select("#{ @table }.id")) - .order(Arel.sql("#{ @table }.created_at DESC, #{ @table }.id DESC")) + def latest_expression + <<~SQL.squish + NOT EXISTS (SELECT 1 FROM #{ @table } newer + WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column } + AND newer.version_no > #{ @table }.version_no) + 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 - expression = case axis - when 'event_type' then "#{ @table }.event_type" - when 'user' then "#{ @table }.created_by_user_id" - when 'month' then "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" - end + expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" : + "#{ @table }.created_by_user_id" grouped = @scope.group(Arel.sql(expression)) - # A derived table counts groups without materialising every group in Ruby. sql = grouped.select(Arel.sql("#{ expression } AS group_key")).to_sql groups = @scope.klass.connection.select_value("SELECT COUNT(*) FROM (#{ sql }) counts_groups") - rows = grouped.order(Arel.sql("#{ expression } ASC")) + group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression + expressions = { 'group' => [group_order], 'count' => ['COUNT(*)'] } + final = ["(#{ expression }) IS NULL ASC", "#{ expression } ASC"] + rows = grouped.order(Arel.sql(order_sql(expressions, final))) .limit(limit).offset((page - 1) * limit).count names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {} { group_count: groups.to_i, @@ -62,6 +74,79 @@ class CountsQuery private + def parse_sort + value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc') + return [] if value.empty? + + values = value.split(',', -1).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( + 'original_created_at' => [ + 'post_versions.original_created_from', 'post_versions.original_created_before'], + '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? @@ -83,22 +168,18 @@ class CountsQuery first = timestamp('from') last = timestamp('before') raise ArgumentError if first && last && first >= last + raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '0')) + @scope = @scope.where(Arel.sql(latest_expression)) if @params['latest'] == '1' @scope = @scope.where("#{ @table }.created_at >= ?", first) if first @scope = @scope.where("#{ @table }.created_at < ?", last) if last if @params['resource_id'].present? - id_column = @resource == 'posts' ? 'post_id' : 'tag_id' - @scope = @scope.where(@table => { id_column => integer('resource_id') }) + @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 - if @params['event_type'].present? - raise ArgumentError unless EVENTS.include?(@params['event_type']) - - @scope = @scope.where(@table => { event_type: @params['event_type'] }) - end return unless @params['tag'].present? raise ArgumentError unless @resource == 'posts' diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index a7693dc..e4b94ba 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -33,9 +33,9 @@ npm run lint If either command cannot be run or fails, report the exact command and failure. -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. +明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。 +fixture、mock、snapshot、テスト補助コード及び既存のテスト差分にも触れない。 +ビルド・lint・静的確認を使い、未確認事項はそのまま報告する。 ## TypeScript @@ -134,9 +134,9 @@ pass or the remaining failure is clearly blocked. - 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と `_untranslated` 規約を使い、language metadata を維持する。 翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。 -- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の - 確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。 - この規約はテストコードの追加・修正を義務付けない。 +- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と + SSR / hydration の整合及び翻訳キーの非露出を検証する。 + i18n の検証を理由にテスト関連作業を開始しない。 - API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に 翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、 アクセシビリティ用ラベルも UI 文言として扱う。 diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx new file mode 100644 index 0000000..55068f0 --- /dev/null +++ b/frontend/src/components/counts/CountsControls.tsx @@ -0,0 +1,145 @@ +import { useId, useState } from 'react' + +import CountsFieldTree from '@/components/counts/CountsFieldTree' +import DateTimeField from '@/components/common/DateTimeField' +import FormField from '@/components/common/FormField' +import { Button } from '@/components/ui/button' +import { normaliseCountsParams } from '@/lib/counts' +import { fetchTagByName } from '@/lib/tags' +import { inputClass } from '@/lib/utils' +import { useLocale } from '@/lib/useLocale' +import { useUITranslation } from '@/lib/useUITranslation' + +import type { CountsResource } from '@/lib/counts' + + +const CountsControls = ( + { resource, applied, open, setOpen, execute }: { + resource: CountsResource + applied: URLSearchParams + open: boolean + setOpen: (open: boolean) => void + execute: (params: URLSearchParams) => void }, +) => { + const { label, message } = useUITranslation ('counts') + const locale = useLocale () + const id = useId () + const [draft, setDraft] = useState (() => new URLSearchParams (applied)) + const [error, setError] = useState (false) + const [pending, setPending] = useState (false) + const mode = draft.get ('mode') ?? 'summary' + const update = (key: string, value: string) => { + setDraft (previous => { + const next = new URLSearchParams (previous) + if (key === 'mode') next.delete ('sort') + if (value || key === 'columns' || key === 'children') next.set (key, value) + else next.delete (key) + return next + }) + } + const submit = async () => { + setError (false) + setPending (true) + try + { + const next = new URLSearchParams (draft) + for (const key of resource === 'posts' ? ['tag'] : ['resource_id']) + { + const value = next.get (key)?.trim () ?? '' + if (value && !/^[1-9][0-9]*$/.test (value)) + { + const tag = await fetchTagByName (value, locale) + if (tag == null) + throw new Error () + next.set (key, String (tag.id)) + } + } + next.delete ('page') + execute (normaliseCountsParams (resource, next)) + } + catch + { + setError (true) + } + finally + { + setPending (false) + } + } + + return ( +
{ + event.preventDefault () + void submit () + }}> +
+ + + +
+ {error &&

{label ('shared:messages.checkInputModern')}

} + +
) +} + + +export default CountsControls diff --git a/frontend/src/components/counts/CountsFieldTree.tsx b/frontend/src/components/counts/CountsFieldTree.tsx new file mode 100644 index 0000000..eb12c39 --- /dev/null +++ b/frontend/src/components/counts/CountsFieldTree.tsx @@ -0,0 +1,72 @@ +import { countChildPaths, fieldsForCounts, selectedCountChildren, + selectedCountColumns } from '@/lib/counts' +import { useUITranslation } from '@/lib/useUITranslation' + +import type { CountField, CountsResource } from '@/lib/counts' + + +const FieldBranch = ( + { field, path, enabled, selected, select }: { + field: CountField + path: string + enabled: boolean + selected: Set + select: (path: string, checked: boolean) => void }, +) => { + const { label } = useUITranslation ('counts') + const checked = selected.has (path) + const control = + return field.children ? ( +
+ {control} +
+ {field.children.map (child => ( + ))} +
+
) :
{control}
+} + + +const CountsFieldTree = ( + { resource, params, update }: { + resource: CountsResource + params: URLSearchParams + update: (key: string, value: string) => void }, +) => { + const { label } = useUITranslation ('counts') + const fields = fieldsForCounts (resource) + const roots = selectedCountColumns (resource, params) + const selected = selectedCountChildren (params) + for (const root of roots) selected.add (root) + const select = (path: string, checked: boolean) => { + if (!path.includes ('.')) + { + const next = new Set (roots) + if (checked) next.add (path) + else next.delete (path) + update ('columns', fields.filter (field => next.has (field.key)) + .map (field => field.key).join (',')) + return + } + if (checked) selected.add (path) + else selected.delete (path) + update ('children', countChildPaths (fields).filter ( + key => key.includes ('.') && selected.has (key)).join (',')) + } + return ( + <> + {fields.map (field => ( + ))} +

{label ('nested.selection')}

+ ) +} + + +export default CountsFieldTree diff --git a/frontend/src/components/counts/CountsNestedTable.tsx b/frontend/src/components/counts/CountsNestedTable.tsx new file mode 100644 index 0000000..37ab970 --- /dev/null +++ b/frontend/src/components/counts/CountsNestedTable.tsx @@ -0,0 +1,101 @@ +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 } from '@/lib/counts' +import { countsLogicalName, countsNestedOrder, countsNestedText } 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 = fields.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 === 'id') + { + if (row.kind === 'external') return String (value) + const target = path === 'parents' ? resource : 'tags' + return + {String (value)} + } + const display = countsLogicalName (row, field, locale) + if (typeof value === 'boolean') + return {value ? '✓' : '✗'} + if (display.language != null) + return {display.text} + if (field === 'name' && typeof row.languageCode === 'string') + return {String (value)} + return display.text + } + return ( +
+ + {title.text} ({rows.length}) + + {columns.length > 0 && rows.length > 0 && ( +
+ field.key)} + identity={`${path}:${params}`} name={title} + className="border text-xs dark:border-slate-700"> + + {columns.map (field => ( + { + 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])) + }}/>))} + + {ordered.map ((row, index) => ( + + {columns.map (field => ( + + {field.children + ? + : scalar (row, field.key)} + ))} + ))} + +
)} +
) +} + + +export default CountsNestedTable diff --git a/frontend/src/components/counts/CountsResizeHandle.tsx b/frontend/src/components/counts/CountsResizeHandle.tsx new file mode 100644 index 0000000..62718f9 --- /dev/null +++ b/frontend/src/components/counts/CountsResizeHandle.tsx @@ -0,0 +1,93 @@ +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 } | 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 ( + { + 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 diff --git a/frontend/src/components/counts/CountsResults.tsx b/frontend/src/components/counts/CountsResults.tsx new file mode 100644 index 0000000..5a21816 --- /dev/null +++ b/frontend/src/components/counts/CountsResults.tsx @@ -0,0 +1,149 @@ +import LocalisedText from '@/components/LocalisedText' +import PrefetchLink from '@/components/PrefetchLink' +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, fieldsForCounts, + readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts' +import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay' +import { useLocale } from '@/lib/useLocale' +import { useUITranslation } from '@/lib/useUITranslation' + +import type { CountColumn, CountRecord, 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 { label, message } = useUITranslation ('counts') + const locale = useLocale () + const summary = (params.get ('mode') ?? 'summary') === 'summary' + const axis = params.get ('axis') ?? 'month' + const columns = selectedCountColumns (resource, params) + const fields = fieldsForCounts (resource) + const sorts = readCountSort (params.get ('sort') ?? (summary ? 'group:asc' : 'created_at:desc')) + const sort = (key: string, additive: boolean) => + update ('sort', writeCountSort (changeCountSort (sorts, key, additive))) + const drillDown = (key: string | number | null): string => { + const next = new URLSearchParams (params) + next.set ('mode', 'details') + next.delete ('page') + next.delete ('sort') + if (axis === 'month') + { + const first = `${key}-01T00:00Z` + const date = new Date (first) + date.setUTCMonth (date.getUTCMonth () + 1) + const last = date.toISOString ().slice (0, 16) + 'Z' + 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) + } + else next.set ('user_id', String (key ?? 'none')) + return `?${next}` + } + const cell = (row: CountsVersion, column: CountColumn) => { + const field = fields.find (field => field.key === column)! + const value = countsCellText (row, column, locale) + if (field.children != null) + { + return + } + switch (column) + { + case 'resource_id': + return + {row.resourceId} + case 'thumbnail': + { + const url = countsImageURL (row.thumbnailBase) + return url == null ? null : + } + case 'url': + case 'thumbnail_base': + return + {value} + case 'is_latest': + return {row.isLatest ? '✓' : '✗'} + case 'user': + return row.createdByUser?.name ? value : ( + + {value}) + case 'category': + return + {value} + default: return value + } + } + return ( +
+ {!summary && columns.length === 0 ?

{label ('nested.noColumns')}

: ( + row.id)}`} + className="text-left text-sm"> + + {summary ? <> + + + : columns.map (column => column === 'thumbnail' + ? + {label (countColumns[column])} + + + : field.key === column)!.label)} + sorts={sorts} change={sort} structured={fields.find ( + field => field.key === column)?.children != null}/>)} + + + {summary ? (data.groups ?? []).map (group => ( + + + + {group.key == null + ? label ('history:bot') : group.name ?? String (group.key)} + + {group.count} + )) : (data.versions ?? []).map (row => ( + + {columns.map (column => ( + field.key === column)!, params, locale)}> + {cell (row, column)}))} + ))} + + )} + {(summary ? data.groups?.length ?? 0 : data.versions?.length ?? 0) === 0 + &&

{label ('empty')}

} +
) +} + + +export default CountsResults diff --git a/frontend/src/components/counts/CountsSelectableTable.tsx b/frontend/src/components/counts/CountsSelectableTable.tsx new file mode 100644 index 0000000..988895f --- /dev/null +++ b/frontend/src/components/counts/CountsSelectableTable.tsx @@ -0,0 +1,222 @@ +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' + +import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout' +import { cn } from '@/lib/utils' +import { useUITranslation } from '@/lib/useUITranslation' + +import type { ClipboardEvent, ReactNode } from 'react' + +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 escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!) + + +const cellAt = (table: HTMLTableElement, point: Point) => + table.tBodies[0]?.rows[point.row]?.cells[point.column] + + +const CountsSelectableTable = ( + { family, columns, identity, children, className, name }: { + family: string + columns: string[] + identity: string + children: ReactNode + className?: string + name?: { text: string; language: string } }, +) => { + const id = useId () + const root = useRef (null) + const dragging = useRef (false) + const touchArmed = useRef (false) + const touchPress = useRef<{ x: number; y: number + timer: ReturnType } | null> (null) + const [stored, setStored] = useState (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 (() => { + const rows = root.current?.tBodies[0]?.rows + if (rows == null) return + for (const [row, tr] of Array.from (rows).entries ()) + { + for (const [column, cell] of Array.from (tr.cells).entries ()) + { + const selected = bounds != null && row >= bounds.top && row <= bounds.bottom + && column >= bounds.left && column <= bounds.right + cell.id = `${id}-${row}-${column}` + cell.setAttribute ('role', 'gridcell') + cell.setAttribute ('aria-selected', String (selected)) + if (selected) cell.dataset.countsSelected = '' + else delete cell.dataset.countsSelected + } + } + }, [bounds, children, id]) + const pointFor = (element: Element | null): Point | null => { + const table = root.current + if (table == null || element == null || !table.contains (element)) return null + let cell = element.closest ('td') + while (cell != null && cell.closest ('table') !== table) + cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null + if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null + return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex, + column: (cell as HTMLTableCellElement).cellIndex } + } + const copy = (event: ClipboardEvent) => { + if (bounds == null || root.current == null || document.activeElement !== root.current) return + event.preventDefault () + event.stopPropagation () + 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) + } + const text = rows.map (row => row.map (value => { + const safe = /^\s*[=+@-]/.test (value) ? `'${value}` : value + return `"${safe.replaceAll ('"', '""')}"` + }).join ('\t')).join ('\r\n') + const html = '' + rows.map (row => '' + row.map (value => + ``).join ('') + + '').join ('') + '
${escapeHTML ( + /^\s*[=+@-]/.test (value) ? `'${value}` : value)}
' + event.clipboardData.setData ('text/plain', text) + try + { + event.clipboardData.setData ('text/html', 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 ( + + 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} + onPointerDown={event => { + const element = event.target as Element + if (element.closest ('table') !== event.currentTarget + || element.closest (interactive) != null || event.button !== 0) return + const point = pointFor (element) + if (point == null) return + event.stopPropagation () + 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 (() => { + 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)) + if (point == null) return + 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 => { + if (touchPress.current != null || touchArmed.current) event.preventDefault () + }} + onKeyDown={event => { + if (event.target !== event.currentTarget) 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' }) + }}> + + {columns.map (key => ( + ))} + {children} +
{label ('selection.hint')}
+
) +} + + +export default CountsSelectableTable diff --git a/frontend/src/components/counts/CountsSortHeader.tsx b/frontend/src/components/counts/CountsSortHeader.tsx new file mode 100644 index 0000000..67711ca --- /dev/null +++ b/frontend/src/components/counts/CountsSortHeader.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef } from 'react' + +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 }: { + field: string + label: { text: string; language: string } + sorts: CountSort[] + change: (key: string, additive: boolean) => void + structured?: boolean }, +) => { + const { message } = useUITranslation ('counts') + const press = useRef<{ x: number; y: number + timer: ReturnType } | 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 }) + + return ( + 1 && priority >= 0) ? 'other' : state}> + + + ) +} + + +export default CountsSortHeader diff --git a/frontend/src/components/counts/CountsTableLayout.tsx b/frontend/src/components/counts/CountsTableLayout.tsx new file mode 100644 index 0000000..16a541b --- /dev/null +++ b/frontend/src/components/counts/CountsTableLayout.tsx @@ -0,0 +1,24 @@ +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> ({ }) + 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 ( + + {children} + ) +} + + +export default CountsTableLayout diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts index f6e2730..4b41e33 100644 --- a/frontend/src/lib/counts.ts +++ b/frontend/src/lib/counts.ts @@ -1,34 +1,160 @@ import { apiGet } from '@/lib/api' import type { LocaleCode } from '@/lib/locales' -import type { PostVersion, TagVersion } from '@/types' +import type { TagNameSnapshot } from '@/types' export type CountsResource = 'posts' | 'tags' +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 = { + 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 CountsResult = { count: number page: number limit: number groupCount?: number groups?: { key: string | number | null; name: string | null; count: number }[] - versions?: (PostVersion | TagVersion)[] } + versions?: CountsVersion[] } +export type CountSort = { key: string; direction: 'asc' | 'desc' } +export type CountField = { key: string; label: string; children?: CountField[] } +export type CountRecord = Record export const countColumns = { - created_at: 'counts:columns.changedAt', resource_id: 'counts:columns.resourceId', - version_no: 'history:version', user: 'history:actor', + thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId', + version_no: 'history:version', is_latest: 'counts:columns.latest', title: 'counts:columns.postTitle', url: 'counts:columns.postURL', - tags: 'counts:columns.tags', added_tags: 'counts:columns.addedTags', - removed_tags: 'counts:columns.removedTags', name: 'history:tagName', - category: 'common:categoryLabel', aliases: 'tags:aliases', parents: 'tags:parents', - deprecated: 'counts:columns.deprecated' } - + thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags', + parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration', + original_created_at: 'shared:labels.originalCreatedAt', + original_created_from: 'counts:columns.originalFrom', + original_created_before: 'counts:columns.originalBefore', + name: 'history:tagName', 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 = keyof typeof countColumns +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 nested: Partial> = { + 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', children: [ + { key: 'beginMs', label: 'counts:columns.begin' }, + { key: 'endMs', label: 'counts:columns.end' }] }, + { 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): CountColumn[] => [ - 'created_at', 'resource_id', 'version_no', 'user', + ...(resource === 'posts' ? ['thumbnail'] as const : []), + 'resource_id', 'version_no', 'is_latest', ...(resource === 'posts' - ? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const - : ['name', 'category', 'aliases', 'parents', 'deprecated'] as const)] + ? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms', + 'original_created_at', 'original_created_from', 'original_created_before'] as const + : ['name', 'names', 'category', 'aliases', 'parents', 'deprecated'] as const), + 'user', 'created_at'] + + +export const fieldsForCounts = (resource: CountsResource): CountField[] => + columnsForCounts (resource).map (key => ({ + key, + label: (key === 'parents' && resource === 'tags') ? 'tags:parents' : countColumns[key], + children: nested[key] })) + + +export const selectedCountColumns = ( + resource: CountsResource, + params: URLSearchParams, +): CountColumn[] => { + const selected = params.has ('columns') ? params.get ('columns')!.split (',') : + columnsForCounts (resource).filter (key => ![ + 'thumbnail', 'original_created_from', 'original_created_before'] + .includes (key)) + return columnsForCounts (resource).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 => + new Set (params.has ('children') ? params.get ('children')!.split (',') : + Object.entries (nested).flatMap (([key, fields]) => + countChildPaths (fields, key))) + + +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 normaliseCountsParams = ( @@ -40,25 +166,75 @@ export const normaliseCountsParams = ( if (!['month', 'user'].includes (params.get ('axis') ?? '')) params.set ('axis', 'month') if (params.has ('columns')) - { - const available = columnsForCounts (resource) - const columns = params.get ('columns')!.split (',') - .filter (column => available.includes (column as CountColumn)) - if (columns.length > 0) - params.set ('columns', [...new Set (columns)].join (',')) - else - params.delete ('columns') - } + params.set ('columns', selectedCountColumns (resource, params).join (',')) return params } +export const countsRequestParams = (params: URLSearchParams): URLSearchParams => { + const result = new URLSearchParams (params) + for (const key of ['columns', 'children', 'child_sort']) + result.delete (key) + return result +} + + export const fetchCounts = ( resource: CountsResource, params: URLSearchParams, locale: LocaleCode, ): Promise => apiGet (`/counts/${resource}`, { - locale, params: Object.fromEntries (params) }) + locale, params: Object.fromEntries (countsRequestParams (params)) }) + + +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 => diff --git a/frontend/src/lib/countsDisplay.ts b/frontend/src/lib/countsDisplay.ts new file mode 100644 index 0000000..ce7cf20 --- /dev/null +++ b/frontend/src/lib/countsDisplay.ts @@ -0,0 +1,190 @@ +import { fieldsForCounts, selectedCountChildren, sortedCountChildren } from '@/lib/counts' +import { translateUIMessage } from '@/lib/i18n' +import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils' + +import type { CountColumn, CountField, CountRecord, + CountsResource, CountsVersion } from '@/lib/counts' +import type { LocaleCode } from '@/lib/locales' + + +export const countsCellText = ( + row: CountsVersion, + column: CountColumn, + locale: LocaleCode, +): string => { + switch (column) + { + case 'resource_id': return String (row.resourceId) + case 'version_no': return String (row.versionNo) + case 'is_latest': + return translateUIMessage (`boolean.${row.isLatest}`, '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_at': + return originalCreatedAtString ( + row.originalCreatedFrom ?? null, row.originalCreatedBefore ?? null, locale) + case 'original_created_from': + case 'original_created_before': + { + const value = column === 'original_created_from' + ? row.originalCreatedFrom : row.originalCreatedBefore + return value == null ? '' : localisedDateString (value, locale) + } + case 'deprecated': + return row.deprecated == null ? '' : localisedDateString (row.deprecated, locale) + case 'user': + if (row.createdByUser == null) + return translateUIMessage ('history:bot', 'counts', locale).text + return row.createdByUser.name + ? `${row.createdByUser.name} (#${row.createdByUser.id})` + : translateUIMessage ('history:anonymous', 'counts', locale, { + id: row.createdByUser.id }).text + case 'created_at': return localisedDateString (row.createdAt, locale) + default: return '' + } +} + + +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 = row[column as keyof CountsVersion] as CountRecord[] ?? [] + return JSON.stringify (countsNestedExport (rows, field.children, column, params, locale)) + } + return countsCellText (row, column, locale) +} + + +const displayNames = new Map () + + +export const countsLogicalName = ( + row: CountRecord, + field: string, + locale: LocaleCode, +): { text: string; language?: string } => { + const value = row[field] + if (value == null) return { 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) + if (field === 'beginMs' || field === 'endMs') return { text: msToTime (Number (value)) } + 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 = fields.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 => + 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) + : countsLogicalName (row, field.key, locale).text]))) +} + + +export const countsNestedText = ( + rows: CountRecord[], + fields: CountField[], + path: string, + params: URLSearchParams, + locale: LocaleCode, +): string => { + const selected = selectedCountChildren (params) + const columns = fields.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 => field.children + ? countsNestedText (row[field.key as keyof CountsVersion] as CountRecord[] ?? [], + field.children, field.key, params, locale) + : countsCellText (row, field.key as CountColumn, 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 + } +} diff --git a/frontend/src/lib/countsTableLayout.ts b/frontend/src/lib/countsTableLayout.ts new file mode 100644 index 0000000..945de5d --- /dev/null +++ b/frontend/src/lib/countsTableLayout.ts @@ -0,0 +1,28 @@ +import { createContext, useContext } from 'react' + +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, + 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, + aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160, + id: 88, versionNo: 88, kind: 140, sections: 240, beginMs: 120, endMs: 120, + 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 (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 +} + + diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0afef1c..a4c54e1 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -91,36 +91,36 @@ export const originalCreatedAtString = ( const unknownLabel = (key: string) => i18n.t (`history:${key}`, { lng: locale }) const space = locale === 'ja' ? ' ' : '\u3000' - if (from && before) + 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) { - const diff = before.getTime () - from.getTime () - - if (diff <= 60_000 /* 1 分 */) - return format (from, 'second') - - if (from.getMinutes () === 0 && before.getMinutes () === 0) + 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) { - 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')})` - } - } + 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')})` + } } + } } + } const rtn = ([from ? format (from, 'second') : '', '〜', diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json index 97e005b..ce6d542 100644 --- a/frontend/src/locales/ja/counts.json +++ b/frontend/src/locales/ja/counts.json @@ -22,14 +22,23 @@ "csvScope": "表示中のページ・選択列を CSV 出力", "empty": "該当する履歴はありません。", "columns": { - "changedAt": "変更日時", - "resourceId": "ID", + "changedAt": "操作日時", + "resourceId": "Id.", "postTitle": "投稿タイトル", "postURL": "投稿 URL", "tags": "記載タグ", - "addedTags": "追加されたタグ", - "removedTags": "削除されたタグ", - "deprecated": "廃止状態" + "deprecated": "廃止状態", + "latest": "最新フラグ", + "thumbnailURL": "サムネール元 URL", + "originalFrom": "オリジナルの投稿日時(開始境界)", + "originalBefore": "オリジナルの投稿日時(終了境界・未満)", + "language": "言語", + "kind": "内部・外部", + "sections": "記載区間", + "begin": "開始", + "end": "終了", + "names": "言語別名称", + "thumbnailDisplay": "サムネール表示" }, "fields": { "from": "開始(端末の時刻帯・以上)", @@ -38,5 +47,37 @@ "tag": "タグ名または ID", "actor": "操作者 ID", "limit": "表示件数" + }, + "latestOnly": "最新のみ", + "boolean": { + "true": "はい", + "false": "いいえ" + }, + "kinds": { + "internal": "内部タグ", + "external": "外部タグ" + }, + "nested": { + "toggle": "表内表を展開・折り畳み", + "selection": "親を OFF にしても子項目の選択は保持します。子項目がすべて OFF の表は件数のみ表示します。", + "noColumns": "出力項目が選択されていません。" + }, + "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 で解除。" + }, + "resize": { + "column": "{{column}}の列幅を変更。境界をドラッグ、タッチでは長押ししてドラッグ。矢印キーでも調整できます。" } } diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx index 07949e5..057e0fd 100644 --- a/frontend/src/pages/CountsResourcePage.tsx +++ b/frontend/src/pages/CountsResourcePage.tsx @@ -1,247 +1,72 @@ import { useQuery } from '@tanstack/react-query' -import { useEffect, useId, useState } from 'react' +import { useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' -import LocalisedText from '@/components/LocalisedText' import PrefetchLink from '@/components/PrefetchLink' -import DateTimeField from '@/components/common/DateTimeField' -import FormField from '@/components/common/FormField' +import CountsControls from '@/components/counts/CountsControls' +import CountsResults from '@/components/counts/CountsResults' +import CountsTableLayout from '@/components/counts/CountsTableLayout' import PageHead from '@/components/common/PageHead' import PageTitle from '@/components/common/PageTitle' import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import SidebarComponent from '@/components/layout/SidebarComponent' import { Button } from '@/components/ui/button' -import { columnsForCounts, countColumns, countsCSV, - fetchCounts, normaliseCountsParams } from '@/lib/counts' +import { countsCSV, countsRequestParams, fieldsForCounts, fetchCounts, + normaliseCountsParams, selectedCountColumns } from '@/lib/counts' +import { countsCSVCell } from '@/lib/countsDisplay' import { countsKeys } from '@/lib/queryKeys' -import { fetchTagByName } from '@/lib/tags' -import { cn, inputClass, localisedDateString } from '@/lib/utils' +import { cn } from '@/lib/utils' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' -import type { CountColumn, CountsResource } from '@/lib/counts' -import type { PostVersion, TagVersion } from '@/types' - - -const CountsControls = ( - { resource, applied, open, setOpen, execute }: { - resource: CountsResource - applied: URLSearchParams - open: boolean - setOpen: (open: boolean) => void - execute: (params: URLSearchParams) => void }, -) => { - const { label, message } = useUITranslation ('counts') - const locale = useLocale () - const id = useId () - const [draft, setDraft] = useState (() => new URLSearchParams (applied)) - const [error, setError] = useState (false) - const [pending, setPending] = useState (false) - const available = columnsForCounts (resource) - const columns = (draft.get ('columns')?.split (',') ?? available) as CountColumn[] - const mode = draft.get ('mode') ?? 'summary' - const update = (key: string, value: string) => { - setDraft (previous => { - const next = new URLSearchParams (previous) - if (value) next.set (key, value) - else next.delete (key) - return next - }) - } - const submit = async () => { - setError (false) - setPending (true) - try - { - const next = new URLSearchParams (draft) - for (const key of resource === 'posts' ? ['tag'] : ['resource_id']) - { - const value = next.get (key)?.trim () ?? '' - if (value && !/^[1-9][0-9]*$/.test (value)) - { - const tag = await fetchTagByName (value, locale) - if (tag == null) - throw new Error () - next.set (key, String (tag.id)) - } - } - next.delete ('page') - execute (normaliseCountsParams (resource, next)) - } - catch - { - setError (true) - } - finally - { - setPending (false) - } - } - - return ( -
{ - event.preventDefault () - void submit () - }}> -
- - - -
- {error &&

{label ('shared:messages.checkInputModern')}

} - -
) -} +import type { CountsResource } from '@/lib/counts' const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { const [source, setParams] = useSearchParams () const params = normaliseCountsParams (resource, source) const canonical = params.toString () + const request = countsRequestParams (params) const [open, setOpen] = useState (false) const [sidebarOpen, setSidebarOpen] = useState (false) const locale = useLocale () const { label, message } = useUITranslation ('counts') const mode = params.get ('mode') ?? 'summary' const axis = params.get ('axis') ?? 'month' - const columns = (params.get ('columns')?.split (',') - ?? columnsForCounts (resource)) as CountColumn[] - const { data, isPending, isError, refetch } = useQuery ({ - queryKey: countsKeys.index (resource, canonical, locale), - queryFn: () => fetchCounts (resource, params, locale), retry: false }) + const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({ + queryKey: countsKeys.index (resource, request.toString (), locale), + queryFn: () => fetchCounts (resource, request, locale), retry: false, + placeholderData: (previous, query) => { + const previousMode = new URLSearchParams (String (query?.queryKey[3] ?? '')) + .get ('mode') ?? 'summary' + return (query?.queryKey[1] === resource && query?.queryKey[2] === locale + && previousMode === mode) ? previous : undefined + } }) useEffect (() => { if (source.toString () !== canonical) setParams (canonical, { replace: true }) }, [canonical, source, setParams]) - const cell = (row: PostVersion | TagVersion, column: CountColumn): string => { - switch (column) - { - case 'created_at': return localisedDateString (row.createdAt, locale) - case 'resource_id': return String ('postId' in row ? row.postId : row.tagId) - case 'version_no': return String (row.versionNo) - case 'user': return row.createdByUser?.name ?? '' - } - if ('postId' in row) - { - if (column === 'title') return row.title.current ?? '' - if (column === 'url') return row.url.current - const tags = row.tags.filter (tag => column === 'added_tags' - ? tag.type === 'added' - : (column === 'removed_tags' ? tag.type === 'removed' : tag.type !== 'removed')) - return tags.map (tag => tag.name).join (' ') - } - switch (column) - { - case 'name': return row.name.current ?? '' - case 'category': return message (`common:categories.${row.category.current}`).text - case 'aliases': - return row.aliases.filter (item => item.type !== 'removed') - .map (item => item.name).join (' ') - case 'parents': - return row.parentTags.filter (item => item.type !== 'removed') - .map (item => item.tag.name).join (' ') - case 'deprecated': return row.deprecatedAt.current ?? '' - default: return '' - } - } - const groups = data?.groups ?? [] - const versions = data?.versions ?? [] - const groupName = (group: typeof groups[number]) => - axis === 'user' ? group.name ?? String (group.key ?? '') : String (group.key) - const drillDown = (key: string | number | null): string => { + const update = (key: string, value: string) => { const next = new URLSearchParams (params) - next.set ('mode', 'details') - next.delete ('page') - if (axis === 'month') - { - const first = `${key}-01T00:00Z` - const date = new Date (first) - date.setUTCMonth (date.getUTCMonth () + 1) - const last = date.toISOString ().slice (0, 16) + 'Z' - const from = next.get ('from') - const before = next.get ('before') - next.set ('from', from != null && from > first ? from : first) - next.set ('before', before != null && before < last ? before : last) - } - else - next.set ('user_id', String (key ?? 'none')) - return `?${next}` + if (value || key === 'sort') next.set (key, value) + else next.delete (key) + if (key === 'sort') next.delete ('page') + setParams (next) } - const groupText = (group: typeof groups[number]) => - group.key == null ? message ('history:bot').text : groupName (group) const exportCSV = () => { + const columns = selectedCountColumns (resource, params) const rows = mode === 'summary' ? [[message (`axes.${axis}`).text, message ('metric').text], - ...groups.map (group => [groupText (group), String (group.count)])] - : [columns.map (column => message (countColumns[column]).text), - ...versions.map (row => columns.map (column => cell (row, column)))] + ...(data?.groups ?? []).map (group => [ + group.key == null ? message ('history:bot').text : group.name ?? String (group.key), + String (group.count)])] + : [columns.map (column => message ( + fieldsForCounts (resource).find (field => field.key === column)!.label).text), + ...(data?.versions ?? []).map (row => columns.map ( + column => countsCSVCell (row, column, resource, params, locale)))] const url = URL.createObjectURL (new Blob ([countsCSV (rows)], { type: 'text/csv;charset=utf-8' })) const link = document.createElement ('a') @@ -252,91 +77,54 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { } return ( -
- - - - - - - {label (`titles.${resource}`)} -
- { - if (next.toString () === canonical) void refetch () - else setParams (next) - }}/> - -
- {isPending &&

{label ('shared:messages.loadingEllipsis')}

} - {isError &&

{label ('common:fetchError')}

} - {data != null && <> -

{label ('total', { count: data.count })}

-
- - - {mode === 'summary' - ? <> - - : <> - {columns.map (column => ( - ))}} - - - {mode === 'summary' ? groups.map (group => ( - - - - )) : versions.map (row => { - const id = 'postId' in row ? row.postId : row.tagId - return - - {columns.map (column => ( - ))} - - })} - -
{label (`axes.${axis}`)}{label ('metric')} - {label (resource === 'posts' ? 'history:post' : 'navigation:tags')} - {label (countColumns[column])}
- {group.key == null ? label ('history:bot') : groupName (group)} - {group.count}
#{id} - {column === 'url' - ? - {cell (row, column)} - : (column === 'category' && 'tagId' in row - ? - {cell (row, column)} - : cell (row, column))}
- {(mode === 'summary' ? groups.length : versions.length) === 0 - &&

{label ('empty')}

} + +
+ + + + + + + {label (`titles.${resource}`)} +
+ { + if (next.toString () === canonical) void refetch () + else setParams (next) + }}/> +
- - } -
-
) + {isPending &&

{label ('shared:messages.loadingEllipsis')}

} + {isError &&

{label ('common:fetchError')}

} + {data != null && <> +

{label ('total', { count: data.count })}

+ + + } + +
+ ) } From 52a8d62be56a43203fecb7bfc6a0fdb7d4169fb9 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 25 Oct 2026 16:25:04 +0900 Subject: [PATCH 04/49] #434 --- .../src/components/counts/CountsControls.tsx | 13 ++- .../components/counts/CountsNestedTable.tsx | 91 ++++++++++--------- .../src/components/counts/CountsResults.tsx | 9 +- .../counts/CountsSelectableTable.tsx | 8 +- .../src/components/counts/countsTable.css | 19 ++++ frontend/src/index.css | 1 + frontend/src/lib/counts.ts | 6 +- frontend/src/lib/countsDisplay.ts | 39 ++++++-- frontend/src/lib/countsTableLayout.ts | 2 +- frontend/src/locales/ja/counts.json | 10 +- frontend/src/pages/CountsResourcePage.tsx | 9 +- 11 files changed, 139 insertions(+), 68 deletions(-) create mode 100644 frontend/src/components/counts/countsTable.css diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx index 55068f0..7ffa0b7 100644 --- a/frontend/src/components/counts/CountsControls.tsx +++ b/frontend/src/components/counts/CountsControls.tsx @@ -14,12 +14,13 @@ import type { CountsResource } from '@/lib/counts' const CountsControls = ( - { resource, applied, open, setOpen, execute }: { + { resource, applied, open, setOpen, execute, displayJSON }: { resource: CountsResource applied: URLSearchParams open: boolean setOpen: (open: boolean) => void - execute: (params: URLSearchParams) => void }, + execute: (params: URLSearchParams) => void + displayJSON: (enabled: boolean) => void }, ) => { const { label, message } = useUITranslation ('counts') const locale = useLocale () @@ -92,6 +93,14 @@ const CountsControls = (
{label ('output')} + {mode === 'summary' ? (
{label ('filters')} diff --git a/frontend/src/components/counts/CountsFieldTree.tsx b/frontend/src/components/counts/CountsFieldTree.tsx index eb12c39..97efc8d 100644 --- a/frontend/src/components/counts/CountsFieldTree.tsx +++ b/frontend/src/components/counts/CountsFieldTree.tsx @@ -1,19 +1,33 @@ -import { countChildPaths, fieldsForCounts, selectedCountChildren, - selectedCountColumns } from '@/lib/counts' +import { DndContext, MouseSensor, TouchSensor, closestCenter, + useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core' + +import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields, + selectedCountChildren, selectedCountColumns } from '@/lib/counts' +import { cn } from '@/lib/utils' import { useUITranslation } from '@/lib/useUITranslation' import type { CountField, CountsResource } from '@/lib/counts' +type BranchProps = { + field: CountField + path: string + enabled: boolean + selected: Set + params: URLSearchParams + select: (path: string, checked: boolean) => void + update: (key: string, value: string) => void + move: (from: string, to: string) => void + previous?: string + next?: string } + const FieldBranch = ( - { field, path, enabled, selected, select }: { - field: CountField - path: string - enabled: boolean - selected: Set - select: (path: string, checked: boolean) => void }, + { field, path, enabled, selected, params, select, update, move, previous, next }: BranchProps, ) => { - const { label } = useUITranslation ('counts') + const { label, message } = useUITranslation ('counts') + const fixed = path === 'thumbnail' + const drag = useDraggable ({ id: field.key, disabled: fixed }) + const drop = useDroppable ({ id: field.key, disabled: fixed }) const checked = selected.has (path) const control = - return field.children ? ( -
- {control} -
- {field.children.map (child => ( - ))} + return ( +
+
+ {fixed ? : ( + )} + {field.children ? ( +
+ {control} +
+ +
+
) :
{control}
}
-
) :
{control}
+
) +} + + +const FieldGroup = ( + { fields, path, params, enabled, selected, select, update }: { + fields: CountField[] + path: string + params: URLSearchParams + enabled: boolean + selected: Set + select: BranchProps['select'] + update: BranchProps['update'] }, +) => { + const { message } = useUITranslation ('counts') + const ordered = orderedCountFields (fields, params, path) + const movable = ordered.filter (field => path !== '' || field.key !== 'thumbnail') + const sensors = useSensors ( + useSensor (MouseSensor, { activationConstraint: { distance: 4 } }), + useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } })) + const move = (from: string, to: string) => + update ('order', reorderCountFields (fields, params, path, from, to)) + return ( + undefined, onDragOver: () => undefined, + onDragMove: () => undefined, + onDragEnd: () => undefined, onDragCancel: () => undefined }, + screenReaderInstructions: { draggable: message ('output').text } }} + onDragEnd={({ active, over }) => { + if (over != null && active.id !== over.id) move (String (active.id), String (over.id)) + }}> + {ordered.map (field => { + const position = movable.findIndex (item => item.key === field.key) + return + })} + ) } @@ -61,9 +139,8 @@ const CountsFieldTree = ( } return ( <> - {fields.map (field => ( - ))} +

{label ('nested.selection')}

) } diff --git a/frontend/src/components/counts/CountsNestedTable.tsx b/frontend/src/components/counts/CountsNestedTable.tsx index 753987a..c0d3ace 100644 --- a/frontend/src/components/counts/CountsNestedTable.tsx +++ b/frontend/src/components/counts/CountsNestedTable.tsx @@ -3,7 +3,7 @@ import PrefetchLink from '@/components/PrefetchLink' import CountsSortHeader from '@/components/counts/CountsSortHeader' import CountsSelectableTable from '@/components/counts/CountsSelectableTable' import { changeCountSort, readCountSort, selectedCountChildren, - writeCountSort } from '@/lib/counts' + writeCountSort, orderedCountFields } from '@/lib/counts' import { countsLogicalName, countsNestedExport, countsNestedOrder, countsNestedText } from '@/lib/countsDisplay' import { useLocale } from '@/lib/useLocale' @@ -25,7 +25,8 @@ const CountsNestedTable = ( const { message } = useUITranslation ('counts') const locale = useLocale () const selected = selectedCountChildren (params) - const columns = fields.filter (field => selected.has (`${path}.${field.key}`)) + 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) })) @@ -50,21 +51,19 @@ const CountsNestedTable = ( return display.text } return ( -
- - {title.text} ({rows.length}) - +
{params.get ('nested_json') === '1' ? (
 	      {JSON.stringify (countsNestedExport (rows, fields, path, params, locale), null, 2)}
 	    
) : (columns.length > 0 && rows.length > 0 && (
+ className="relative isolate max-h-72 w-full min-w-0 overflow-auto"> field.key)} identity={`${path}:${params}`} name={title} - className="border text-xs dark:border-slate-700"> + className="text-xs"> {columns.map (field => ( @@ -101,7 +100,7 @@ const CountsNestedTable = ( ))}
))} -
) +
) } diff --git a/frontend/src/components/counts/CountsResults.tsx b/frontend/src/components/counts/CountsResults.tsx index 6184951..14ee4ca 100644 --- a/frontend/src/components/counts/CountsResults.tsx +++ b/frontend/src/components/counts/CountsResults.tsx @@ -8,6 +8,7 @@ import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview' import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts, readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts' import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay' +import useDialogue from '@/lib/dialogues/useDialogue' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' @@ -25,6 +26,7 @@ const CountsResults = ( ) => { const { label, message } = useUITranslation ('counts') const locale = useLocale () + const dialogue = useDialogue () const summary = (params.get ('mode') ?? 'summary') === 'summary' const axis = params.get ('axis') ?? 'month' const columns = selectedCountColumns (resource, params) @@ -70,8 +72,22 @@ const CountsResults = ( case 'thumbnail': { const url = countsImageURL (row.thumbnailBase) - return url == null ? null : + return url == null ? null : ( + ) } case 'url': case 'thumbnail_base': @@ -107,7 +123,8 @@ const CountsResults = ( : columns.map (column => column === 'thumbnail' - ? + ? {label (countColumns[column])} diff --git a/frontend/src/components/counts/CountsSelectableTable.tsx b/frontend/src/components/counts/CountsSelectableTable.tsx index ba669ad..075043e 100644 --- a/frontend/src/components/counts/CountsSelectableTable.tsx +++ b/frontend/src/components/counts/CountsSelectableTable.tsx @@ -1,20 +1,20 @@ -import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' +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 { 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 escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character => - ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!) - - const cellAt = (table: HTMLTableElement, point: Point) => table.tBodies[0]?.rows[point.row]?.cells[point.column] @@ -34,6 +34,9 @@ const CountsSelectableTable = ( const touchArmed = useRef (false) const touchPress = useRef<{ x: number; y: number timer: ReturnType } | null> (null) + const [menu, setMenu] = useState<{ x: number; y: number; point: Point + identity: string } | null> (null) + const suppressClick = useRef (false) const [stored, setStored] = useState (null) const selection = stored?.identity === identity ? stored : null const layout = useCountsTableLayout () @@ -87,10 +90,19 @@ const CountsSelectableTable = ( return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex, column: (cell as HTMLTableCellElement).cellIndex } } - const copy = (event: ClipboardEvent) => { - if (bounds == null || root.current == null || document.activeElement !== root.current) return - event.preventDefault () - event.stopPropagation () + 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 const rows: string[][] = [] for (let row = bounds.top; row <= bounds.bottom; ++row) { @@ -102,18 +114,50 @@ const CountsSelectableTable = ( } rows.push (values) } - const text = rows.map (row => row.map (value => { - const safe = /^\s*[=+@-]/.test (value) ? `'${value}` : value - return `"${safe.replaceAll ('"', '""')}"` - }).join ('\t')).join ('\r\n') - const html = '' + rows.map (row => '' + row.map (value => - ``).join ('') - + '').join ('') + '
${escapeHTML ( - /^\s*[=+@-]/.test (value) ? `'${value}` : value)}
' - event.clipboardData.setData ('text/plain', text) + 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) => { + 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', html) + event.clipboardData.setData ('text/html', payload.html) } catch { @@ -140,10 +184,18 @@ const CountsSelectableTable = ( '[&>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 - || element.closest (interactive) != null || event.button !== 0) return + 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 () @@ -153,6 +205,7 @@ const CountsSelectableTable = ( const point = pointFor (element) 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 }) @@ -169,6 +222,8 @@ const CountsSelectableTable = ( 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) @@ -182,6 +237,10 @@ const CountsSelectableTable = ( if (!dragging.current || event.buttons === 0) return const point = pointFor (document.elementFromPoint (event.clientX, 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 }) @@ -191,10 +250,27 @@ const CountsSelectableTable = ( if (event.target === event.currentTarget) finish () }} onContextMenu={event => { - if (touchPress.current != null || touchArmed.current) event.preventDefault () + const element = event.target as Element + if (element.closest ('table') !== event.currentTarget) return + const point = pointFor (element) + 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) @@ -221,6 +297,8 @@ const CountsSelectableTable = ( ))} {children} + {menu?.identity === identity && } ) } diff --git a/frontend/src/components/counts/CountsSortHeader.tsx b/frontend/src/components/counts/CountsSortHeader.tsx index 67711ca..d14b5e8 100644 --- a/frontend/src/components/counts/CountsSortHeader.tsx +++ b/frontend/src/components/counts/CountsSortHeader.tsx @@ -36,7 +36,8 @@ const CountsSortHeader = ( direction: message (`sort.${state}`).text }) return ( - 1 && priority >= 0) ? 'other' : state}>
{error &&

{label ('shared:messages.checkInputModern')}

} @@ -115,7 +118,9 @@ const CountsControls = ( }}/> {label ('nested.asJSON')} - {mode === 'summary' ? ( + {(mode === 'state' || mode === 'trend') ? ( +

{label ( + mode === 'trend' ? 'state.trend' : 'state.list')}

) : (mode === 'summary' ? (
From 8642225b94d4419d76c730d5c830b188c9ebe262 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 25 Oct 2026 23:08:48 +0900 Subject: [PATCH 07/49] #434 --- .../controllers/concerns/counts_listing.rb | 3 +- .../controllers/counts_exports_controller.rb | 20 ++ .../controllers/post_versions_controller.rb | 8 +- backend/app/models/post_version.rb | 5 + .../representations/counts_version_repr.rb | 14 +- .../app/services/counts_export_projection.rb | 229 ++++++++++++++++++ backend/app/services/counts_exporter.rb | 138 +++++++++++ backend/app/services/counts_query.rb | 19 +- backend/app/services/counts_state_query.rb | 127 +++++++--- backend/config/routes.rb | 2 + .../src/components/counts/CountsControls.tsx | 99 +++++--- .../src/components/counts/CountsFieldTree.tsx | 36 ++- .../src/components/counts/CountsResults.tsx | 53 +--- .../components/counts/CountsStateFilters.tsx | 63 ----- .../components/counts/CountsStateResults.tsx | 56 ----- .../counts/CountsSummaryResults.tsx | 63 +++++ frontend/src/lib/counts.ts | 68 ++++-- frontend/src/lib/countsDisplay.ts | 32 +++ frontend/src/lib/countsState.ts | 90 +++++-- frontend/src/locales/ja/counts.json | 14 +- frontend/src/pages/CountsResourcePage.tsx | 141 ++++++----- 21 files changed, 920 insertions(+), 360 deletions(-) create mode 100644 backend/app/controllers/counts_exports_controller.rb create mode 100644 backend/app/services/counts_export_projection.rb create mode 100644 backend/app/services/counts_exporter.rb delete mode 100644 frontend/src/components/counts/CountsStateFilters.tsx delete mode 100644 frontend/src/components/counts/CountsStateResults.tsx create mode 100644 frontend/src/components/counts/CountsSummaryResults.tsx diff --git a/backend/app/controllers/concerns/counts_listing.rb b/backend/app/controllers/concerns/counts_listing.rb index a1a4769..bbaeb2e 100644 --- a/backend/app/controllers/concerns/counts_listing.rb +++ b/backend/app/controllers/concerns/counts_listing.rb @@ -6,7 +6,8 @@ module CountsListing resource = is_a?(CountsPostsController) ? 'posts' : 'tags' query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale') .merge('locale' => locale.code)) - result = { count: query.count, page: query.page, limit: query.limit } + result = { 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 diff --git a/backend/app/controllers/counts_exports_controller.rb b/backend/app/controllers/counts_exports_controller.rb new file mode 100644 index 0000000..cb4d9de --- /dev/null +++ b/backend/app/controllers/counts_exports_controller.rb @@ -0,0 +1,20 @@ +class CountsExportsController < ApplicationController + wrap_parameters false + + def index + locale = resolve_locale! + exporter = CountsExporter.new(request.request_parameters.merge(request.query_parameters), + language_code: locale.language_code) + response.headers['Content-Type'] = exporter.format == 'json' ? + 'application/json; charset=utf-8' : 'text/csv; charset=utf-8' + response.headers['Content-Disposition'] = + "attachment; filename=\"counts-#{ exporter.resource }.#{ exporter.format }\"" + response.headers['Cache-Control'] = 'private, no-store' + response.headers['X-Accel-Buffering'] = 'no' + # Rack::ETag otherwise materialises the response to calculate a digest. + response.headers['Last-Modified'] = Time.current.httpdate + self.response_body = exporter.body + rescue ArgumentError, TypeError, JSON::ParserError + head :bad_request + end +end diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index e37bdb5..23cd083 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -38,13 +38,7 @@ class PostVersionsController < ApplicationController private def version_scope - 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 + PostVersion.with_previous_version .select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url', diff --git a/backend/app/models/post_version.rb b/backend/app/models/post_version.rb index 8fe52bb..745505d 100644 --- a/backend/app/models/post_version.rb +++ b/backend/app/models/post_version.rb @@ -1,6 +1,11 @@ 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') + } + belongs_to :post belongs_to :parent, class_name: 'Post', optional: true diff --git a/backend/app/representations/counts_version_repr.rb b/backend/app/representations/counts_version_repr.rb index e3a7c5b..0d8b44d 100644 --- a/backend/app/representations/counts_version_repr.rb +++ b/backend/app/representations/counts_version_repr.rb @@ -1,11 +1,13 @@ # Counts uses recorded values only; history/diff endpoints retain their own contract. class CountsVersionRepr - def self.many(rows, resource:, language_code:) - new(rows.to_a, resource:, language_code:).serialise + 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:) + def initialize(rows, resource:, language_code:, at:, inclusive:) @rows = 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 @@ -45,7 +47,11 @@ class CountsVersionRepr .uniq pairs.each_slice(500) do |batch| placeholders = batch.map { '(?, ?)' }.join(', ') - TagVersion.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten]) + scope = 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 diff --git a/backend/app/services/counts_export_projection.rb b/backend/app/services/counts_export_projection.rb new file mode 100644 index 0000000..6ab6370 --- /dev/null +++ b/backend/app/services/counts_export_projection.rb @@ -0,0 +1,229 @@ +require 'set' + +# Export identifiers are stable; UI translations are supplied separately as column labels. +class CountsExportProjection + ROOT_KEYS = { '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_at' => 'original_created_at', + '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'] }.freeze + + attr_reader :columns, :headers + + def initialize(params, resource:, summary:, tags:) + @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 + @columns = params.fetch('columns', allowed.join(',')).split(',') + 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 = params.fetch('order', '').split(',') + @sorts = params.fetch('child_sort', '').split(',').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') ? params['children'].split(',').to_set : nil + valid = CHILDREN.flat_map do |key, fields| + paths(fields, key) + end + raise ArgumentError if @selected && (@selected.to_a - valid).any? + 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] + when 'original_created_at' + { from: row[:original_created_from], before: row[:original_created_before] } + 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 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 diff --git a/backend/app/services/counts_exporter.rb b/backend/app/services/counts_exporter.rb new file mode 100644 index 0000000..8b797d9 --- /dev/null +++ b/backend/app/services/counts_exporter.rb @@ -0,0 +1,138 @@ +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:) + allowed = (CountsQuery::KEYS + CountsStateQuery::KEYS + EXTRA_KEYS).uniq + raise ArgumentError if (params.keys - allowed).any? + 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 + Enumerator.new do |output| + ApplicationRecord.transaction(isolation: :repeatable_read) do + first_row = true + started = false + pages do |page| + 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: @params.fetch('order', '').split(','), 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 + end + end + 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 diff --git a/backend/app/services/counts_query.rb b/backend/app/services/counts_query.rb index 48e9533..832b98a 100644 --- a/backend/app/services/counts_query.rb +++ b/backend/app/services/counts_query.rb @@ -10,7 +10,7 @@ class CountsQuery 'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze KEYS = [ 'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest', - 'user_id', 'resource_id', 'tag', 'columns', 'sort'].freeze + 'user_id', 'resource_id', 'tag', 'columns', 'sort', 'at'].freeze attr_reader :page, :limit, :mode, :axis @@ -41,15 +41,19 @@ class CountsQuery 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) + AND newer.version_no > #{ @table }.version_no #{ cutoff }) SQL end @@ -59,7 +63,7 @@ class CountsQuery .limit(limit).offset((page - 1) * limit) end - def summary + 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 @@ -68,13 +72,13 @@ class CountsQuery "#{ group_order } AS group_name")).to_sql connection = @scope.klass.connection source = "FROM (#{ sql }) counts_groups" - groups = connection.select_value("SELECT COUNT(*) #{ source }") + 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, + { group_count: groups&.to_i, groups: rows.map do |row| { key: row['group_key'], name: axis == 'user' ? row['group_name'] : nil, @@ -168,18 +172,21 @@ class CountsQuery def timestamp(key) value = @params[key].presence return nil unless value - raise ArgumentError unless value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z\z/) + 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 diff --git a/backend/app/services/counts_state_query.rb b/backend/app/services/counts_state_query.rb index 1b263bb..48d70c7 100644 --- a/backend/app/services/counts_state_query.rb +++ b/backend/app/services/counts_state_query.rb @@ -1,9 +1,11 @@ -# State at an exclusive UTC boundary, reconstructed only from recorded snapshots. +# 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'].freeze + '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) } @@ -12,14 +14,21 @@ class CountsStateQuery @connection = ApplicationRecord.connection @language = language_code @resource = params.fetch('resource', 'posts') - @mode = params.fetch('mode', 'state') + @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 ['state', 'trend', 'snapshot'].include?(@mode) + 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? @@ -28,15 +37,19 @@ class CountsStateQuery raise ArgumentError if @mode == 'trend' && !@tag @now = Time.current.utc - @at = timestamp('at') || @now.change(usec: 0) + @at = timestamp('at') || @now raise ArgumentError if @at > @now end - def call + def call(metadata: true) + @metadata = metadata # Keep totals, rows, and detail pages on one repeatable-read database snapshot. ApplicationRecord.transaction do - @post_start = PostVersion.minimum(:created_at) - @tag_start = TagVersion.minimum(:created_at) + unless @history_loaded + @post_start = PostVersion.minimum(:created_at) + @tag_start = TagVersion.minimum(:created_at) + @history_loaded = true + end result = { page: @page, limit: @limit, at: @at.iso8601(6), coverage: { basis: 'recorded_snapshots', exact: false, post_history_from: @post_start&.iso8601(6), @@ -44,14 +57,45 @@ class CountsStateQuery case @mode when 'trend' result.merge(trend) - when 'snapshot' - result.merge(totals: totals).merge(details) + when 'summary' + result.merge(metadata ? { totals: totals } : {}).merge(summary) + when 'details', 'snapshot' + result.merge(metadata ? { totals: totals } : {}).merge(details) else - result.merge(totals: totals).merge(listing) + 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) @@ -78,10 +122,10 @@ class CountsStateQuery def latest_condition(table, row, boundary) key = table == 'post_versions' ? 'post_id' : 'tag_id' <<~SQL.squish - #{ row }.created_at < #{ boundary } AND NOT EXISTS ( + #{ row }.created_at #{ @inclusive ? '<=' : '<' } #{ boundary } AND NOT EXISTS ( SELECT 1 FROM #{ table } newer WHERE newer.#{ key } = #{ row }.#{ key } - AND newer.created_at < #{ boundary } + AND newer.created_at #{ @inclusive ? '<=' : '<' } #{ boundary } AND newer.version_no > #{ row }.version_no) SQL end @@ -110,7 +154,7 @@ class CountsStateQuery def ctes boundary = quote(@at) <<~SQL - WITH post_at AS (#{ state_sql('post_versions', boundary, live: true) }), + 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 ( @@ -133,7 +177,7 @@ class CountsStateQuery row = @connection.select_one(<<~SQL) #{ ctes } SELECT (SELECT COUNT(*) FROM post_at) AS posts, - (SELECT COUNT(*) FROM tag_at WHERE event_type <> 'discard') AS tags, + (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, @@ -148,7 +192,9 @@ class CountsStateQuery 'deprecated_tags' => available?(@tag_start, @at) ? row['deprecated_tags'] : nil) end - def available?(first, boundary) = first && first < boundary + def available?(first, boundary) + first && (@inclusive ? first <= boundary : first < boundary) + end def listing path = quote("$.#{ JSON.generate(@language) }.name") @@ -176,7 +222,9 @@ class CountsStateQuery 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 = @connection.select_value("#{ prefix } SELECT COUNT(*) #{ source } #{ where }").to_i + 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', @@ -195,13 +243,14 @@ class CountsStateQuery end } end - def details - table = @resource == 'posts' ? 'post_versions' : 'tag_versions' - model = @resource == 'posts' ? PostVersion : TagVersion - scope = model.joins("INNER JOIN (#{ state_sql(table, quote(@at), live: true) }) " + - "counts_at ON counts_at.id = #{ table }.id") + # 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 = if resource == 'posts' scope.where("#{ membership(table) } AND #{ tag_visible(quote(@at)) }") elsif @kind == 'internal' scope.where(tag_id: @tag) @@ -209,12 +258,36 @@ class CountsStateQuery 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' => 'details', 'latest' => '0') - query = CountsQuery.new(scope, @resource, params, latest_expression: '1') - { count: query.count, + .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) } + language_code: @language, at: @at, + inclusive: @inclusive) } end def next_boundary(time) diff --git a/backend/config/routes.rb b/backend/config/routes.rb index a350568..002149b 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + 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' diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx index 6a17e09..22ab6b1 100644 --- a/frontend/src/components/counts/CountsControls.tsx +++ b/frontend/src/components/counts/CountsControls.tsx @@ -1,28 +1,31 @@ import { useEffect, useId, useState } from 'react' -import CountsStateFilters from '@/components/counts/CountsStateFilters' import CountsFieldTree from '@/components/counts/CountsFieldTree' import DateTimeField from '@/components/common/DateTimeField' import FormField from '@/components/common/FormField' import { Button } from '@/components/ui/button' import { isCountsState, normaliseCountsParams } from '@/lib/counts' +import { countsSummaryFields } from '@/lib/countsState' import { fetchTagByName } from '@/lib/tags' import { inputClass } from '@/lib/utils' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' +import type { ReactNode } from 'react' + import type { CountsResource } from '@/lib/counts' const CountsControls = ( - { resource, applied, open, setOpen, execute, displayJSON, reorder }: { + { resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: { resource: CountsResource applied: URLSearchParams open: boolean setOpen: (open: boolean) => void execute: (params: URLSearchParams) => void displayJSON: (enabled: boolean) => void - reorder: (value: string) => void }, + reorder: (value: string) => void + actions?: ReactNode }, ) => { const { label, message } = useUITranslation ('counts') const locale = useLocale () @@ -43,13 +46,18 @@ const CountsControls = ( }, [order, nestedJSON]) const [error, setError] = useState (false) const [pending, setPending] = useState (false) - const mode = draft.get ('mode') ?? 'state' + const mode = draft.get ('mode') ?? 'summary' const state = isCountsState (draft) + const axes = (state && resource === 'posts') ? ['tags', 'month', 'user'] : ['month', 'user'] const update = (key: string, value: string) => { setDraft (previous => { const next = new URLSearchParams (previous) - if (key === 'mode') next.delete ('sort') - if (value || key === 'columns' || key === 'children') next.set (key, value) + if (['mode', 'axis', 'basis'].includes (key)) next.delete ('sort') + if (key === 'axis' || key === 'basis') next.delete ('summary_columns') + if (key === 'basis' && value === 'history' && next.get ('axis') === 'tags') + next.set ('axis', 'month') + if (value || ['columns', 'children', 'summary_columns'].includes (key)) + next.set (key, value) else next.delete (key) return next }) @@ -60,7 +68,7 @@ const CountsControls = ( try { const next = new URLSearchParams (draft) - for (const key of (state || resource === 'posts') ? ['tag'] : ['resource_id']) + for (const key of resource === 'posts' ? ['tag'] : ['resource_id']) { const value = next.get (key)?.trim () ?? '' if (value && !/^[1-9][0-9]*$/.test (value)) @@ -99,14 +107,16 @@ const CountsControls = ( + {actions} {error &&

{label ('shared:messages.checkInputModern')}

} -