このコミットが含まれているのは:
2026-10-27 12:39:32 +09:00
コミット 2706789fa4
10個のファイルの変更、94行の追加、30行の削除
+22
ファイルの表示
@@ -13,6 +13,28 @@
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。 秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。 秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
## Mandatory naming contract
- メソッド・関数など、実際に処理を実行する callable の名前以外の自前識別子に
動詞を使用しない。状態は名詞・形容詞・形容詞的な語で表す。今後の全開発に適用する。
- 変数、引数、プロパティ、型定義内の項目、API・URL パラメータ、JSON キー、
DB カラムにも適用する。`is_` / `is`、`has_` / `has`、`can_` / `can`、
`should_` / `should` などの動詞・助動詞由来の接頭辞を使用しない。
状態を格納する識別子に `show`、`enable` 等の動詞も使用しない。
- 形容詞で状態を表せる場合はその語を使う。例:`is_latest` / `isLatest` は
`latest`、`is_active` は `active`、`is_deleted` は `deleted`、
`is_visible` は `visible`、表示状態は `visible`、有効状態は `enabled`。
- 名詞で真偽値を表す場合は原則 `_flg`、camelCase では `Flg` を付ける。
例:`is_admin` は `admin_flg`、`is_owner` は `owner_flg`、
`isAdmin` は `adminFlg`。クエリパラメータでは意味が明確なら接尾辞を省略できる。
- callable の `setState`、`onChange` 等や、外部ライブラリ・フレームワーク所定の
API 名は変更しない。外部値を自前の状態へ保持する場合は境界で適切な別名を付ける。
言語固有の表記規約を維持し、予約語や曖昧さは規則を満たす明確な代替名で避ける。
- 改名時は Frontend / Backend、URL、API、出力の名称を整合させる。
旧 URL・API の互換性は入出力境界で扱い、旧命名を新しい内部コードへ伝播させない。
DB の改名は既存データを保持する追加マイグレーションで扱い、適用済みのものを
書き換えない。メソッド名・外部 API・文字列値等を機械的な一括置換で破壊しない。
## Project overview ## Project overview
BTRC Hub / タグ広場 is a split Rails API and React frontend repository. BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
+11
ファイルの表示
@@ -15,6 +15,17 @@
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。 - 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。 export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。
## Mandatory naming contract
- ルート `AGENTS.md` の必須命名規約を適用する。メソッド等の callable 以外の
自前識別子に動詞を使わず、形容詞の状態名、名詞の真偽値には `_flg` を使用する。
例:`is_latest` は `latest`、`is_admin` は `admin_flg`。
- 引数、属性、SQL alias、API・URL パラメータ、JSON キー、DB カラムも対象とする。
Rails 等が定義する外部 API 名や、処理を実行するメソッド名は変更しない。
- 旧 API・URL の互換性は解析・シリアライズ境界で扱い、内部定義は新名に統一する。
DB カラムの改名はデータを保持する追加マイグレーションで設計し、適用済みの
マイグレーションを書き換えない。マイグレーションの実行には個別指示を要する。
## Scope ## Scope
These rules apply to work under `backend/`. These rules apply to work under `backend/`.
+7 -5
ファイルの表示
@@ -43,12 +43,9 @@ class CountsTableQuery
ApplicationRecord.transaction do ApplicationRecord.transaction do
result = metadata.merge(mode == 'summary' ? summary : details) result = metadata.merge(mode == 'summary' ? summary : details)
visible = mode == 'summary' ? result[:visible_output_count] : result[:count] visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
# A scalar result has one row regardless of private attributes or predicates.
public_count = result[:excluded_count].zero? || (mode == 'summary' && axes.rows.empty?)
result.merge(visible_output_count: visible, result.merge(visible_output_count: visible,
output_count: public_count ? visible : nil, output_count: visible,
output_count_scope: public_count ? output_count_scope: 'authorised_results',
'before_authorisation' : 'unavailable_private_history',
output_count_unit: 'table_body_rows', output_count_unit: 'table_body_rows',
page_grand_total_repeated: mode == 'summary' && axes.rows.any? && page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
result[:group_count] > limit) result[:group_count] > limit)
@@ -57,7 +54,12 @@ class CountsTableQuery
def metadata def metadata
@population_counts ||= HistoryAccess.population_counts(@definition.model) @population_counts ||= HistoryAccess.population_counts(@definition.model)
# Global exclusions are not exclusions for this filtered query. Do not expose
# private predicate matches through a condition-dependent count.
exclusions_known = @population_counts[:excluded_count].zero?
@population_counts.merge( @population_counts.merge(
excluded_count: exclusions_known ? 0 : nil,
excluded_count_scope: exclusions_known ? 'current_query' : 'unavailable_private_history',
population_count_scope: 'all_recorded_versions', population_count_scope: 'all_recorded_versions',
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence, page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
definition: population.history ? 'history' : 'latest', definition: population.history ? 'history' : 'latest',
+11
ファイルの表示
@@ -15,6 +15,17 @@
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。 - 秘匿は React / CSS では実装しない。認可済み API のみを使う。
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。 認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
## Mandatory naming contract
- ルート `AGENTS.md` の必須命名規約を適用する。非 callable の自前識別子に
動詞を使わず、形容詞の状態名、名詞の真偽値には `Flg` を使用する。
例:`isLatest` は `latest`、`isAdmin` は `adminFlg`。
- React / TanStack Query 等の外部 API 名は維持する。取得した値を自前の状態に
保持するときは、必要に応じて分割代入等の境界で別名を付ける。
callable の state setter、event handler、callback は動詞名を使用できる。
- 型、表示項目キー、URL、API、コピー・エクスポートのフィールド参照を整合させる。
旧キーの互換性は共通の入出力境界で処理し、新しい内部状態には旧名を使用しない。
## Scope ## Scope
These rules apply to work under `frontend/`. These rules apply to work under `frontend/`.
+13 -7
ファイルの表示
@@ -22,7 +22,7 @@ import { initialiseUserSession } from '@/lib/users'
import { prepareHelpPage } from '@/lib/help' import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, legacyLocalePath, import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales' switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys' import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
import { switchPostFilterLocale } from '@/lib/posts' import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki' import { switchWikiLocale } from '@/lib/wiki'
import { useLocale } from '@/lib/useLocale' import { useLocale } from '@/lib/useLocale'
@@ -63,7 +63,10 @@ const lazyPage = <Props extends object,> (
}) })
const Deferred = lazy (preload) const Deferred = lazy (preload)
// A preloaded page must render synchronously on the first hydration pass. // A preloaded page must render synchronously on the first hydration pass.
const Page = (props: Props) => createElement (loaded ?? Deferred, props) const Page = (props: Props) => {
const [Component] = useState (() => loaded ?? Deferred)
return createElement (Component, props)
}
return Object.assign (Page, { preload }) return Object.assign (Page, { preload })
} }
@@ -77,7 +80,6 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage')) const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
const MaterialSyncSuppressionsPage = lazyPage ( const MaterialSyncSuppressionsPage = lazyPage (
() => import ('@/pages/materials/MaterialSyncSuppressionsPage')) () => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
const CountsPage = lazyPage (() => import ('@/pages/CountsPage'))
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage')) const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
const MorePage = lazyPage (() => import ('@/pages/MorePage')) const MorePage = lazyPage (() => import ('@/pages/MorePage'))
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage')) const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
@@ -196,8 +198,8 @@ export const pageRoutes = (
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>, { id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja' handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } }, ? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'counts', element: <CountsPage/>, { path: 'counts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsPage.preload } }, handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>, { path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload } }, handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>, { path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
@@ -276,10 +278,14 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const next = typeof action === 'function' ? action (authority.current) : action const next = typeof action === 'function' ? action (authority.current) : action
if (next?.id !== authority.current?.id || next?.role !== authority.current?.role) if (next?.id !== authority.current?.id || next?.role !== authority.current?.role)
{ {
client.cancelQueries () const filters = {
client.removeQueries ({ predicate: query => query.queryKey[0] !== 'session' }) predicate: (query: { queryKey: readonly unknown[] }) =>
isAuthorityDependentQuery (query.queryKey) }
void client.cancelQueries (filters)
client.removeQueries (filters)
} }
authority.current = next authority.current = next
client.setQueryData (sessionKeys.current, next)
setUserState (next) setUserState (next)
}, [client]) }, [client])
const [status, setStatus] = useState (200) const [status, setStatus] = useState (200)
+2 -1
ファイルの表示
@@ -61,7 +61,8 @@ export type CountsPivotHeader = { values: (string | number | null)[]
kind: 'data' | 'subtotal' | 'grand_total' } kind: 'data' | 'subtotal' | 'grand_total' }
export type CountsResult = { export type CountsResult = {
populationCount?: number populationCount?: number
excludedCount?: number excludedCount?: number | null
excludedCountScope?: string
outputCount?: number | null outputCount?: number | null
visibleOutputCount?: number visibleOutputCount?: number
outputCountScope?: 'before_authorisation' | 'unavailable_private_history' outputCountScope?: 'before_authorisation' | 'unavailable_private_history'
+6
ファイルの表示
@@ -14,6 +14,12 @@ export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
export const countsResources: CountsResource[] = [ export const countsResources: CountsResource[] = [
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials'] 'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
// Navigation visibility only; supported resources and direct URLs remain available.
// The external-tag entry currently uses Nico tag history.
export const visibleCountsResources = [
'posts', 'tags', 'nico_tags'] as const satisfies readonly CountsResource[]
export type CountsFieldMetadata = { export type CountsFieldMetadata = {
key: string; type: CountsFieldType; nullable: boolean; operators: string[] key: string; type: CountsFieldType; nullable: boolean; operators: string[]
labels?: string[]; multiple?: boolean } labels?: string[]; multiple?: boolean }
+9
ファイルの表示
@@ -157,6 +157,15 @@ export const sessionKeys = {
settings: (id: number) => ['session', id, 'settings'] as const, settings: (id: number) => ['session', id, 'settings'] as const,
themeSlots: (id: number) => ['session', id, 'themeSlots'] as const } themeSlots: (id: number) => ['session', id, 'themeSlots'] as const }
export const isAuthorityDependentQuery = (key: readonly unknown[]): boolean => {
// Only the public locale catalogue and the explicitly synchronised session survive.
const publicLocaleFlg = key.length === 2
&& key[0] === localesKeys.index[0] && key[1] === localesKeys.index[1]
const currentSessionFlg = key.length === 2
&& key[0] === sessionKeys.current[0] && key[1] === sessionKeys.current[1]
return !publicLocaleFlg && !currentSessionFlg
}
export const theatresKeys = { export const theatresKeys = {
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const, show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
programmes: (id: string, locale: LocaleCode) => programmes: (id: string, locale: LocaleCode) =>
+2 -2
ファイルの表示
@@ -147,7 +147,7 @@
"resources": { "resources": {
"posts": "広場", "posts": "広場",
"tags": "タグ", "tags": "タグ",
"nico_tags": "ニコニコタグ", "nico_tags": "外部タグ",
"tag_descriptions": "タグ説明", "tag_descriptions": "タグ説明",
"tag_groups": "タググループ", "tag_groups": "タググループ",
"materials": "素材" "materials": "素材"
@@ -172,7 +172,7 @@
"changedField": "変更項目", "changedField": "変更項目",
"eventType": "異動区分", "eventType": "異動区分",
"count": "件数", "count": "件数",
"outputTotal": "綜件数:{{value}} 件", "outputTotal": "件数:{{value}} 件",
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)", "excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
"recordTotal": "閲覧可能件数:{{count}}", "recordTotal": "閲覧可能件数:{{count}}",
"grain": "粒度", "grain": "粒度",
+11 -15
ファイルの表示
@@ -19,7 +19,7 @@ import { countsRequestParams, countsDefinitionParams, countsExportPayload, field
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts' isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay' import { countsExportLabels } from '@/lib/countsDisplay'
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel, import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
countsResources, countsFieldLabel, visibleCountsResources, countsFieldLabel,
fetchCountsDefinition } fetchCountsDefinition }
from '@/lib/countsDefinitions' from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys' import { countsKeys } from '@/lib/queryKeys'
@@ -211,9 +211,6 @@ const CountsResourceContent = (
const waiting = running || (!definitionError && (definition == null const waiting = running || (!definitionError && (definition == null
|| isPending || isFetching || isPlaceholderData || isPending || isFetching || isPlaceholderData
|| (!params.has ('execution_at') && !isError))) || (!params.has ('execution_at') && !isError)))
const loadingParams = pendingRun.current?.params ?? params
const compatible = data != null && ['definition', 'mode', 'rows', 'cols'].every (
key => loadingParams.get (key) === resultParams.get (key))
const update = (key: string, value: string, extra?: Record<string, string>) => { const update = (key: string, value: string, extra?: Record<string, string>) => {
@@ -287,7 +284,7 @@ const CountsResourceContent = (
lang={message ('target').language}> lang={message ('target').language}>
<h2 className="mb-2 font-semibold">{label ('target')}</h2> <h2 className="mb-2 font-semibold">{label ('target')}</h2>
<ul className="space-y-1"> <ul className="space-y-1">
{countsResources.map (target => <li key={target}> {visibleCountsResources.map (target => <li key={target}>
<button type="button" className="flex w-full items-center gap-1 py-1 text-left <button type="button" className="flex w-full items-center gap-1 py-1 text-left
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
dark:focus-visible:ring-blue-400" dark:focus-visible:ring-blue-400"
@@ -352,16 +349,15 @@ const CountsResourceContent = (
{(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>} {(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden" <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
aria-busy={waiting}> aria-busy={waiting}>
<div inert={waiting} aria-hidden={waiting || undefined} <div inert={waiting} className="flex min-h-0 flex-1 flex-col">
className={cn ('flex min-h-0 flex-1 flex-col',
waiting && (compatible ? 'pointer-events-none opacity-40' : 'invisible'))}>
{definition != null && data != null && <> {definition != null && data != null && <>
{data.excludedCount != null && ( <p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm"> <span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span> {data.excludedCountScope === 'current_query'
<span className="text-xs text-slate-500 dark:text-slate-400"> && data.excludedCount != null && data.excludedCount > 0 && (
{label ('excludedRecords', { count: data.excludedCount })}</span> <span className="text-xs text-slate-500 dark:text-slate-400">
</p>)} {label ('excludedRecords', { count: data.excludedCount })}</span>)}
</p>
{resultMode === 'summary' ? <CountsSummaryResults resource={resource} {resultMode === 'summary' ? <CountsSummaryResults resource={resource}
data={data} params={resultParams} data={data} params={resultParams}
pending={waiting} update={update}/> : ( pending={waiting} update={update}/> : (
@@ -372,7 +368,7 @@ const CountsResourceContent = (
</>} </>}
</div> </div>
{waiting && <div role="status" aria-live="polite" {waiting && <div role="status" aria-live="polite"
className="pointer-events-none absolute inset-0 flex items-center justify-center"> className="pointer-events-none absolute right-2 top-2">
<span className="rounded border border-slate-300 bg-white px-2 py-1 <span className="rounded border border-slate-300 bg-white px-2 py-1
text-sm text-slate-900 dark:border-slate-600 text-sm text-slate-900 dark:border-slate-600
dark:bg-slate-950 dark:text-slate-100"> dark:bg-slate-950 dark:text-slate-100">