From 6235b293f0c70edb8fbb2609c53039eaaf790ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=A6=E3=82=8B=E3=81=9E?= Date: Fri, 24 Apr 2026 02:21:26 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=BF=E3=82=B0=E5=B1=A5=E6=AD=B4=E3=83=9A?= =?UTF-8?q?=E3=83=BC=E3=82=B8=20(#321)=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #321 #321 Co-authored-by: miteruzo Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/330 --- .../controllers/tag_versions_controller.rb | 92 ++++++++ backend/config/routes.rb | 1 + backend/spec/requests/tag_versions_spec.rb | 218 ++++++++++++++++++ frontend/src/App.tsx | 2 + frontend/src/components/TopNav.tsx | 4 +- frontend/src/lib/prefetchers.ts | 19 +- frontend/src/lib/queryKeys.ts | 8 +- frontend/src/lib/tags.ts | 13 +- frontend/src/pages/tags/TagHistoryPage.tsx | 212 +++++++++++++++++ frontend/src/types.ts | 11 + 10 files changed, 572 insertions(+), 8 deletions(-) create mode 100644 backend/app/controllers/tag_versions_controller.rb create mode 100644 backend/spec/requests/tag_versions_spec.rb create mode 100644 frontend/src/pages/tags/TagHistoryPage.tsx diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb new file mode 100644 index 0000000..0958c75 --- /dev/null +++ b/backend/app/controllers/tag_versions_controller.rb @@ -0,0 +1,92 @@ +class TagVersionsController < ApplicationController + def index + tag_id = params[:id].presence + page = (params[:page].presence || 1).to_i + limit = (params[:limit].presence || 20).to_i + + page = 1 if page < 1 + limit = 1 if limit < 1 + + 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.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids') + q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id + + count = q.except(:select, :order, :limit, :offset).count + + versions = q.order(Arel.sql('tag_versions.created_at DESC, tag_versions.id DESC')) + .limit(limit) + .offset(offset) + + render json: { versions: serialise_versions(versions), count: } + end + + private + + def serialise_versions rows + user_ids = rows.map(&:created_by_user_id).compact.uniq + users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h + + rows.map do |row| + cur_aliases = split_values(row.aliases) + prev_aliases = split_values(row.attributes['prev_aliases']) + + cur_parent_tag_ids = split_parent_tag_ids(row.parent_tag_ids) + prev_parent_tag_ids = split_parent_tag_ids(row.attributes['prev_parent_tag_ids']) + + all_parent_tag_ids = (cur_parent_tag_ids | prev_parent_tag_ids) + + tags_by_id = + Tag + .includes(:tag_name, :materials, { tag_name: :wiki_page }) + .where(id: all_parent_tag_ids) + .index_by(&:id) + + parent_tags = + build_version_values(cur_parent_tag_ids, prev_parent_tag_ids, key: :tag_id) + .map do |h| + { tag: TagRepr.base(tags_by_id[h[:tag_id]]), + type: h[:type] } + end + + { tag_id: row.tag_id, + version_no: row.version_no, + event_type: row.event_type, + name: { current: row.name, prev: row.attributes['prev_name'] }, + category: { current: row.category, prev: row.attributes['prev_category'] }, + aliases: build_version_values(cur_aliases, prev_aliases, key: :name), + parent_tags:, + created_at: row.created_at.iso8601, + created_by_user: row.created_by_user_id && + { id: row.created_by_user_id, + name: users_by_id[row.created_by_user_id] } } + end + end + + def build_version_values cur_values, prev_values, key: + (cur_values | prev_values).map do |value| + type = + if cur_values.include?(value) && prev_values.include?(value) + 'context' + elsif cur_values.include?(value) + 'added' + else + 'removed' + end + + { key => value, type: } + end + end + + def split_values(values) = values.to_s.split(/\s+/).reject(&:blank?) + + def split_parent_tag_ids(values) = split_values(values).map(&:to_i) +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 373bb17..363dbf4 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -10,6 +10,7 @@ Rails.application.routes.draw do collection do get :autocomplete get :'with-depth', action: :with_depth + get :versions, to: 'tag_versions#index' scope :name do get ':name/deerjikists', action: :deerjikists_by_name diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb new file mode 100644 index 0000000..f1d92e7 --- /dev/null +++ b/backend/spec/requests/tag_versions_spec.rb @@ -0,0 +1,218 @@ +require 'rails_helper' + +RSpec.describe 'TagVersions API', type: :request do + let(:member) { create(:user, :member, name: 'version member') } + + let!(:tag) { create(:tag, name: 'tag_versions_target', category: :general) } + let!(:other_tag) { create(:tag, name: 'tag_versions_other', category: :general) } + + let!(:parent_shared) { create(:tag, name: 'parent_shared', category: :general) } + let!(:parent_old) { create(:tag, name: 'parent_old', category: :general) } + let!(:parent_new) { create(:tag, name: 'parent_new', category: :general) } + let!(:other_parent) { create(:tag, name: 'other_parent', category: :general) } + + let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) } + let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) } + let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) } + + def create_tag_version!( + tag:, + version_no:, + event_type:, + name:, + category:, + aliases: [], + parent_tags: [], + created_by_user:, + created_at: + ) + TagVersion.create!( + tag: tag, + version_no: version_no, + event_type: event_type, + name: name, + category: category, + aliases: Array(aliases).join(' '), + parent_tag_ids: Array(parent_tags).map(&:id).join(' '), + created_by_user: created_by_user, + created_at: created_at + ) + end + + let!(:v1) do + create_tag_version!( + tag: tag, + version_no: 1, + event_type: 'create', + name: 'old_tag_name', + category: 'general', + aliases: ['alias_shared', 'alias_old'], + parent_tags: [parent_shared, parent_old], + created_by_user: member, + created_at: t_v1 + ) + end + + let!(:v2) do + create_tag_version!( + tag: tag, + version_no: 2, + event_type: 'update', + name: 'new_tag_name', + category: 'meme', + aliases: ['alias_shared', 'alias_new'], + parent_tags: [parent_shared, parent_new], + created_by_user: member, + created_at: t_v2 + ) + end + + let!(:other_v1) do + create_tag_version!( + tag: other_tag, + version_no: 1, + event_type: 'create', + name: 'other_tag_name', + category: 'general', + aliases: ['other_alias'], + parent_tags: [other_parent], + created_by_user: member, + created_at: t_other + ) + end + + describe 'GET /tags/versions' do + it 'returns all versions in reverse chronological order when id is omitted' do + get '/tags/versions' + + expect(response).to have_http_status(:ok) + expect(json).to include('versions', 'count') + expect(json.fetch('count')).to eq(3) + + versions = json.fetch('versions') + + expect(versions.map { |v| [v['tag_id'], v['version_no']] }).to eq([ + [other_tag.id, 1], + [tag.id, 2], + [tag.id, 1] + ]) + end + + it 'returns versions for the specified tag with diffs' do + get '/tags/versions', params: { id: tag.id } + + expect(response).to have_http_status(:ok) + expect(json).to include('versions', 'count') + expect(json.fetch('count')).to eq(2) + + versions = json.fetch('versions') + expect(versions.map { |v| v['tag_id'] }.uniq).to eq([tag.id]) + expect(versions.map { |v| v['version_no'] }).to eq([2, 1]) + + latest = versions.first + expect(latest).to include( + 'tag_id' => tag.id, + 'version_no' => 2, + 'event_type' => 'update', + 'created_by_user' => { + 'id' => member.id, + 'name' => member.name + } + ) + + expect(latest.fetch('name')).to eq( + 'current' => 'new_tag_name', + 'prev' => 'old_tag_name' + ) + expect(latest.fetch('category')).to eq( + 'current' => 'meme', + 'prev' => 'general' + ) + expect(latest.fetch('aliases')).to include( + { 'name' => 'alias_shared', 'type' => 'context' }, + { 'name' => 'alias_new', 'type' => 'added' }, + { 'name' => 'alias_old', 'type' => 'removed' } + ) + expect(latest.fetch('parent_tags')).to include( + a_hash_including( + 'type' => 'context', + 'tag' => a_hash_including( + 'id' => parent_shared.id + ) + ), + a_hash_including( + 'type' => 'added', + 'tag' => a_hash_including( + 'id' => parent_new.id + ) + ), + a_hash_including( + 'type' => 'removed', + 'tag' => a_hash_including( + 'id' => parent_old.id + ) + ) + ) + expect(latest.fetch('created_at')).to eq(t_v2.iso8601) + + first = versions.second + expect(first).to include( + 'tag_id' => tag.id, + 'version_no' => 1, + 'event_type' => 'create', + 'created_by_user' => { + 'id' => member.id, + 'name' => member.name + } + ) + expect(first.fetch('name')).to eq( + 'current' => 'old_tag_name', + 'prev' => nil + ) + expect(first.fetch('category')).to eq( + 'current' => 'general', + 'prev' => nil + ) + expect(first.fetch('aliases')).to include( + { 'name' => 'alias_shared', 'type' => 'added' }, + { 'name' => 'alias_old', 'type' => 'added' } + ) + expect(first.fetch('parent_tags')).to include( + a_hash_including( + 'type' => 'added', + 'tag' => a_hash_including( + 'id' => parent_shared.id + ) + ), + a_hash_including( + 'type' => 'added', + 'tag' => a_hash_including( + 'id' => parent_old.id + ) + ) + ) + expect(first.fetch('created_at')).to eq(t_v1.iso8601) + end + + it 'returns empty when the specified tag has no versions' do + fresh_tag = create(:tag, name: 'no_versions_tag', category: :general) + + get '/tags/versions', params: { id: fresh_tag.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('versions')).to eq([]) + expect(json.fetch('count')).to eq(0) + end + + it 'clamps page and limit to at least 1' do + get '/tags/versions', params: { id: tag.id, page: 0, limit: 0 } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + + versions = json.fetch('versions') + expect(versions.size).to eq(1) + expect(versions.first['version_no']).to eq(2) + end + end +end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f8178f6..7f44d7d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import PostSearchPage from '@/pages/posts/PostSearchPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' import SettingPage from '@/pages/users/SettingPage' import TagDetailPage from '@/pages/tags/TagDetailPage' +import TagHistoryPage from '@/pages/tags/TagHistoryPage' import TagListPage from '@/pages/tags/TagListPage' import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage' @@ -58,6 +59,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }> }/> diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index c06f1b9..6a8e732 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -41,12 +41,14 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { { name: 'タグ', to: '/tags', subMenu: [ { name: 'マスタ', to: '/tags' }, { name: 'ニコニコ連携', to: '/tags/nico' }, + { name: '履歴', to: '/tags/changes' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, { component: , visible: tagFlg }, { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, visible: tagFlg }, - { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, visible: false }] }, + { name: '履歴', to: `/tags/changes?id=${ tag?.id }`, + visible: tagFlg && tag?.category !== 'nico' }] }, { name: '素材', to: '/materials', visible: false, subMenu: [ { name: '一覧', to: '/materials' }, { name: '検索', to: '/materials/search', visible: false }, diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 166daa8..5dc9d70 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -3,7 +3,7 @@ import { match } from 'path-to-regexp' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' -import { fetchTagByName, fetchTag, fetchTags } from '@/lib/tags' +import { fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' @@ -183,6 +183,17 @@ const prefetchTagShow: Prefetcher = async (qc, url) => { } +const prefetchTagChanges: Prefetcher = async (qc, url) => { + const id = url.searchParams.get ('id') + const page = Number (url.searchParams.get ('page') || 1) + const limit = Number (url.searchParams.get ('limit') || 20) + + await qc.prefetchQuery ({ + queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), + queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) +} + + export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), run: prefetchPostsIndex }, @@ -195,8 +206,10 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] && Boolean (mWiki (u.pathname))), run: prefetchWikiPageShow }, { test: u => u.pathname === '/tags', run: prefetchTagsIndex }, - { test: u => u.pathname !== '/tags/nico' && Boolean (mTag (u.pathname)), - run: prefetchTagShow }] + { test: u => (!(['/tags/nico', '/tags/changes'].includes (u.pathname)) + && Boolean (mTag (u.pathname))), + run: prefetchTagShow }, + { test: u => u.pathname === '/tags/changes', run: prefetchTagChanges }] export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise => { diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 610c847..97bae56 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -9,9 +9,11 @@ export const postsKeys = { ['posts', 'changes', p] as const } export const tagsKeys = { - root: ['tags'] as const, - index: (p: FetchTagsParams) => ['tags', 'index', p] as const, - show: (name: string) => ['tags', name] as const } + root: ['tags'] as const, + index: (p: FetchTagsParams) => ['tags', 'index', p] as const, + show: (name: string) => ['tags', name] as const, + changes: (p: { id?: string; page: number; limit: number }) => + ['tags', 'changes', p] as const } export const wikiKeys = { root: ['wiki'] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 8a7829f..e2c95c3 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,6 +1,6 @@ import { apiGet } from '@/lib/api' -import type { FetchTagsParams, Tag } from '@/types' +import type { FetchTagsParams, Tag, TagVersion } from '@/types' export const fetchTags = async ( @@ -45,3 +45,14 @@ export const fetchTagByName = async (name: string): Promise => { return null } } + + +export const fetchTagChanges = async ( + { id, page, limit }: { + id?: string + page: number + limit: number }, +): Promise<{ + versions: TagVersion[] + count: number }> => + await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx new file mode 100644 index 0000000..30e96c9 --- /dev/null +++ b/frontend/src/pages/tags/TagHistoryPage.tsx @@ -0,0 +1,212 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { Helmet } from 'react-helmet-async' +import { useLocation } from 'react-router-dom' + +import PrefetchLink from '@/components/PrefetchLink' +import PageTitle from '@/components/common/PageTitle' +import Pagination from '@/components/common/Pagination' +import MainArea from '@/components/layout/MainArea' +import { toast } from '@/components/ui/use-toast' +import { SITE_TITLE } from '@/config' +import { CATEGORY_NAMES } from '@/consts' +import { apiPut } from '@/lib/api' +import { postsKeys, tagsKeys } from '@/lib/queryKeys' +import { fetchTagChanges } from '@/lib/tags' +import { cn, dateString } from '@/lib/utils' + +import type { FC } from 'react' + + +const renderDiff = (diff: { current: string | null; prev: string | null }) => ( + <> + {(diff.prev && diff.prev !== diff.current) && ( + <> + + {diff.prev} + + {diff.current &&
} + )} + {diff.current} + ) + + +export default (() => { + const location = useLocation () + const query = new URLSearchParams (location.search) + const id = query.get ('id') + const page = Number (query.get ('page') ?? 1) + const limit = Number (query.get ('limit') ?? 20) + + const { data, isLoading: loading } = useQuery ({ + queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), + queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) + const changes = data?.versions ?? [] + const totalPages = data ? Math.ceil (data.count / limit) : 0 + + const qc = useQueryClient () + + useEffect (() => { + document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) + }, [location.search]) + + return ( + + + {`タグ定義変更履歴 | ${ SITE_TITLE }`} + + + + タグ定義変更履歴 + {id && <>: タグ {#{id}}} + + + {loading ? 'Loading...' : ( + <> +
+ + + {/* 版 */} + + {/* 名称 */} + + {/* カテゴリ */} + + {/* 別名 */} + + {/* 上位タグ */} + + {/* 更新日時 */} + + {/* (差戻ボタン) */} + + + + + + + + + + + + + + + + {changes.map (change => ( + + + + + + + + + ))} + +
名称カテゴリ別名上位タグ更新日時 +
{change.tagId}.{change.versionNo}{renderDiff (change.name)} + {renderDiff ({ + current: CATEGORY_NAMES[change.category.current], + prev: (change.category.prev + && CATEGORY_NAMES[change.category.prev]) })} + + {change.aliases.map ((tag, i) => ( + tag.type === 'added' + ? ( + + {tag.name} + ) + : ( + tag.type === 'removed' + ? ( + + {tag.name} + ) + : ( + + {tag.name} + ))))} + + {change.parentTags.map ((tag, i) => ( + tag.type === 'added' + ? ( + + {tag.tag.name} + ) + : ( + tag.type === 'removed' + ? ( + + {tag.tag.name} + ) + : ( + + {tag.tag.name} + ))))} + + {change.createdByUser + ? ( + + {change.createdByUser.name + || `名もなきニジラー(#${ change.createdByUser.id })`} + ) + : 'bot 操作'} +
+ {dateString (change.createdAt)} +
+ { + e.preventDefault () + + if (!(confirm ( + `タグ『${ change.name.current }』を版 ${ + change.versionNo } に差戻します.\nよろしいですか?`))) + return + + try + { + await apiPut ( + `/tags/${ change.tagId }`, + { name: change.name.current, + category: change.category.current, + aliases: + change.aliases + .filter (t => t.type !== 'removed') + .map (t => t.name) + .join (' '), + parent_tags: + change.parentTags + .filter (t => t.type !== 'removed') + .map (t => t.tag.name) + .join (' ') }) + + qc.invalidateQueries ({ queryKey: postsKeys.root }) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) + toast ({ description: '差戻しました.' }) + } + catch + { + toast ({ description: '差戻に失敗……' }) + } + }}> + 復元 + +
+
+ + + )} +
) +}) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8adb5ff..d5eb53e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -175,6 +175,17 @@ export type Tag = { children?: Tag[] matchedAlias?: string | null } +export type TagVersion = { + tagId: number + versionNo: number + eventType: 'create' | 'update' | 'discard' | 'restore' + name: { current: string; prev: string | null } + category: { current: Category; prev: Category | null } + aliases: { name: string; type: 'context' | 'added' | 'removed' }[] + parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[] + createdAt: string + createdByUser: { id: number; name: string | null } | null } + export type Theatre = { id: number name: string | null