このコミットが含まれているのは:
@@ -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
|
||||
|
||||
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
|
||||
|
||||
@@ -15,6 +15,17 @@
|
||||
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
|
||||
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
|
||||
|
||||
These rules apply to work under `backend/`.
|
||||
|
||||
@@ -43,12 +43,9 @@ class CountsTableQuery
|
||||
ApplicationRecord.transaction do
|
||||
result = metadata.merge(mode == 'summary' ? summary : details)
|
||||
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
|
||||
# A scalar result has one row regardless of private attributes or predicates.
|
||||
public_count = result[:excluded_count].zero? || (mode == 'summary' && axes.rows.empty?)
|
||||
result.merge(visible_output_count: visible,
|
||||
output_count: public_count ? visible : nil,
|
||||
output_count_scope: public_count ?
|
||||
'before_authorisation' : 'unavailable_private_history',
|
||||
output_count: visible,
|
||||
output_count_scope: 'authorised_results',
|
||||
output_count_unit: 'table_body_rows',
|
||||
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
|
||||
result[:group_count] > limit)
|
||||
@@ -57,7 +54,12 @@ class CountsTableQuery
|
||||
|
||||
def metadata
|
||||
@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(
|
||||
excluded_count: exclusions_known ? 0 : nil,
|
||||
excluded_count_scope: exclusions_known ? 'current_query' : 'unavailable_private_history',
|
||||
population_count_scope: 'all_recorded_versions',
|
||||
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
||||
definition: population.history ? 'history' : 'latest',
|
||||
|
||||
@@ -15,6 +15,17 @@
|
||||
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
|
||||
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、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
|
||||
|
||||
These rules apply to work under `frontend/`.
|
||||
|
||||
+13
-7
@@ -22,7 +22,7 @@ import { initialiseUserSession } from '@/lib/users'
|
||||
import { prepareHelpPage } from '@/lib/help'
|
||||
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
||||
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
||||
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||
import { switchPostFilterLocale } from '@/lib/posts'
|
||||
import { switchWikiLocale } from '@/lib/wiki'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
@@ -63,7 +63,10 @@ const lazyPage = <Props extends object,> (
|
||||
})
|
||||
const Deferred = lazy (preload)
|
||||
// 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 })
|
||||
}
|
||||
|
||||
@@ -77,7 +80,6 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList
|
||||
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
|
||||
const MaterialSyncSuppressionsPage = lazyPage (
|
||||
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
|
||||
const CountsPage = lazyPage (() => import ('@/pages/CountsPage'))
|
||||
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
|
||||
const MorePage = lazyPage (() => import ('@/pages/MorePage'))
|
||||
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
|
||||
@@ -196,8 +198,8 @@ export const pageRoutes = (
|
||||
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
|
||||
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
|
||||
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
|
||||
{ path: 'counts', element: <CountsPage/>,
|
||||
handle: { preload: CountsPage.preload } },
|
||||
{ path: 'counts', element: <CountsResourcePage resource="posts"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
|
||||
handle: { preload: CountsResourcePage.preload } },
|
||||
{ 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
|
||||
if (next?.id !== authority.current?.id || next?.role !== authority.current?.role)
|
||||
{
|
||||
client.cancelQueries ()
|
||||
client.removeQueries ({ predicate: query => query.queryKey[0] !== 'session' })
|
||||
const filters = {
|
||||
predicate: (query: { queryKey: readonly unknown[] }) =>
|
||||
isAuthorityDependentQuery (query.queryKey) }
|
||||
void client.cancelQueries (filters)
|
||||
client.removeQueries (filters)
|
||||
}
|
||||
authority.current = next
|
||||
client.setQueryData (sessionKeys.current, next)
|
||||
setUserState (next)
|
||||
}, [client])
|
||||
const [status, setStatus] = useState (200)
|
||||
|
||||
@@ -61,7 +61,8 @@ export type CountsPivotHeader = { values: (string | number | null)[]
|
||||
kind: 'data' | 'subtotal' | 'grand_total' }
|
||||
export type CountsResult = {
|
||||
populationCount?: number
|
||||
excludedCount?: number
|
||||
excludedCount?: number | null
|
||||
excludedCountScope?: string
|
||||
outputCount?: number | null
|
||||
visibleOutputCount?: number
|
||||
outputCountScope?: 'before_authorisation' | 'unavailable_private_history'
|
||||
|
||||
@@ -14,6 +14,12 @@ export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
|
||||
|
||||
export const countsResources: CountsResource[] = [
|
||||
'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 = {
|
||||
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
|
||||
labels?: string[]; multiple?: boolean }
|
||||
|
||||
@@ -157,6 +157,15 @@ export const sessionKeys = {
|
||||
settings: (id: number) => ['session', id, 'settings'] 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 = {
|
||||
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
|
||||
programmes: (id: string, locale: LocaleCode) =>
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
"resources": {
|
||||
"posts": "広場",
|
||||
"tags": "タグ",
|
||||
"nico_tags": "ニコニコタグ",
|
||||
"nico_tags": "外部タグ",
|
||||
"tag_descriptions": "タグ説明",
|
||||
"tag_groups": "タググループ",
|
||||
"materials": "素材"
|
||||
@@ -172,7 +172,7 @@
|
||||
"changedField": "変更項目",
|
||||
"eventType": "異動区分",
|
||||
"count": "件数",
|
||||
"outputTotal": "綜件数:{{value}} 件",
|
||||
"outputTotal": "件数:{{value}} 件",
|
||||
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
|
||||
"recordTotal": "閲覧可能件数:{{count}}",
|
||||
"grain": "粒度",
|
||||
|
||||
@@ -19,7 +19,7 @@ import { countsRequestParams, countsDefinitionParams, countsExportPayload, field
|
||||
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||
import { countsExportLabels } from '@/lib/countsDisplay'
|
||||
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
||||
countsResources, countsFieldLabel,
|
||||
visibleCountsResources, countsFieldLabel,
|
||||
fetchCountsDefinition }
|
||||
from '@/lib/countsDefinitions'
|
||||
import { countsKeys } from '@/lib/queryKeys'
|
||||
@@ -211,9 +211,6 @@ const CountsResourceContent = (
|
||||
const waiting = running || (!definitionError && (definition == null
|
||||
|| isPending || isFetching || isPlaceholderData
|
||||
|| (!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>) => {
|
||||
@@ -287,7 +284,7 @@ const CountsResourceContent = (
|
||||
lang={message ('target').language}>
|
||||
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
||||
<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
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
|
||||
dark:focus-visible:ring-blue-400"
|
||||
@@ -352,16 +349,15 @@ const CountsResourceContent = (
|
||||
{(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
aria-busy={waiting}>
|
||||
<div inert={waiting} aria-hidden={waiting || undefined}
|
||||
className={cn ('flex min-h-0 flex-1 flex-col',
|
||||
waiting && (compatible ? 'pointer-events-none opacity-40' : 'invisible'))}>
|
||||
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
||||
{definition != null && data != null && <>
|
||||
{data.excludedCount != null && (
|
||||
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{label ('excludedRecords', { count: data.excludedCount })}</span>
|
||||
</p>)}
|
||||
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||
{data.excludedCountScope === 'current_query'
|
||||
&& data.excludedCount != null && data.excludedCount > 0 && (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{label ('excludedRecords', { count: data.excludedCount })}</span>)}
|
||||
</p>
|
||||
{resultMode === 'summary' ? <CountsSummaryResults resource={resource}
|
||||
data={data} params={resultParams}
|
||||
pending={waiting} update={update}/> : (
|
||||
@@ -372,7 +368,7 @@ const CountsResourceContent = (
|
||||
</>}
|
||||
</div>
|
||||
{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
|
||||
text-sm text-slate-900 dark:border-slate-600
|
||||
dark:bg-slate-950 dark:text-slate-100">
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする