diff --git a/backend/app/models/tag_name_sanitisation_rule.rb b/backend/app/models/tag_name_sanitisation_rule.rb index bb13a53..37cde5f 100644 --- a/backend/app/models/tag_name_sanitisation_rule.rb +++ b/backend/app/models/tag_name_sanitisation_rule.rb @@ -10,8 +10,9 @@ class TagNameSanitisationRule < ApplicationRecord validate :source_pattern_must_be_regexp class << self - def sanitise(name) = - rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) } + def sanitise(name) + rules.reduce(name.dup) { |name, (pattern, replacement)| name.gsub(pattern, replacement) } + end def apply! TagName.find_each do |tn| diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index 450921e..d5a4bdb 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -143,7 +143,8 @@ namespace :nico do desired_tag_ids = kept_tag_ids.to_a datum['tags'].each do |raw| - tag = ExternalTag.find_or_create_by!(platform: :nico, name: raw) + name = TagNameSanitisationRule.sanitise(raw) + tag = ExternalTag.find_or_create_by!(platform: :nico, name:) unless tag.nico_tag_versions.exists? NicoTagVersionRecorder.record!(external_tag: tag, diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index dbca0ee..696df06 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -15,6 +15,11 @@ RSpec.describe 'nico:sync' do NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id) end + def create_nico_sanitisation_rules! + TagNameSanitisationRule.create!(priority: 20, source_pattern: '\\?', replacement: '_') + TagNameSanitisationRule.create!(priority: 40, source_pattren: '_$', replacement: '') + end + it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do # 既存 post(正規表現で拾われるURL) post = Post.create!( @@ -435,17 +440,24 @@ RSpec.describe 'nico:sync' do expect(post.tags.map(&:name)).not_to include('bot操作') end - it '外部タグが新規記載されたとき,その時点の連携タグを記載する' do + it 'サニタイズ後の既存外部タグを再利用し、その連携タグを記載する' do post = create_nico_sync_post! + create_nico_sanitisation_rules! + external_tag = create_external_tag!('AAA') linked_tag = create_tag!('spec_linked', category: :general) link_nico_to_tag!(external_tag, linked_tag) - run_nico_sync_with_tags!(['AAA']) + expect { + run_nico_sync_with_tags!(['AAA?']) + }.not_to change(ExternalTag, :count) - expect(post.reload.external_tags).to include(external_tag) + post.reload + + expect(post.external_tags).to contain_exactly(external_tag) expect(post.tags).to include(linked_tag) + expect(ExternalTag.exists?(platform: :nico, name: 'AAA?')).to be(false) end it '外部タグに差分がない場合,内外マッピングが変はっても連携タグを再評価しない' do @@ -520,4 +532,51 @@ RSpec.describe 'nico:sync' do # 再記載時点の内外マッピングが新たに適用される. expect(post.tags).to include(new_linked_tag) end + + it '外部タグだけの変更では bot を付けず、投稿履歴を記録する' do + post = create_nico_sync_post! + PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) + + create_nico_sanitisation_rules! + + expect { + run_nico_sync_with_tags!(['AAA?', 'AAA?']) + }.to change(PostVersion, :count).by(1) + .and change(ExternalTag, :count).by(1) + .and change(PostExternalTag, :count).by(1) + .and change(NicoTagVersion, :count).by(1) + .and change(TagName, :count).by(0) + + external = post.reload.external_tags.sole + + expect(external).to have_attributes( + platform: 'nico', + name: 'AAA') + expect(ExternalTag.exists?(platform: :nico, name: 'AAA?')).to be(false) + + expect(post.tags.map(&:name)).not_to include('bot操作') + expect(post.post_versions.order(:version_no).last.tags_json) + .to include('external_tag_id' => external.id) + + expect { + run_nico_sync_with_tags!(['AAA?']) + }.to change(PostVersion, :count).by(0) + .and change(NicoTagVersion, :count).by(0) + + expect { + run_nico_sync_with_tags!([]) + }.to change(PostVersion, :count).by(1) + + expect(post.reload.external_tags).to be_empty + expect(post.tags.map(&:name)).not_to include('bot操作') + end + + it 'nico: prefix を含む従来の規則で外部タグ名をサニタイズする' do + post = create_nico_sync_post! + + run_nico_sync_with_tags!(['foo:_bar']) + + expect(post.reload.external_tags.sole) + .to have_attributes(platform: 'nico', name: 'foo:_bar') + end end diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index 49f9aed..7c74006 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -38,6 +38,7 @@ const DraggableDroppableTagRow: FC = ({ { normal: { duration: .2, ease: 'easeOut' as const } }, ) const dndId = `tag-node:${ pathKey }` + const dndDisabled = tag.category === 'nico' const downPosRef = useRef<{ x: number; y: number } | null> (null) const armedRef = useRef (false) @@ -62,7 +63,8 @@ const DraggableDroppableTagRow: FC = ({ const { attributes, listeners, setNodeRef: setDragRef, - transform } = useDraggable ({ id: dndId, + transform } = useDraggable ({ id: dndId, + disabled: dndDisabled, data: { kind: 'tag', dndId, tagId: tag.id, @@ -70,8 +72,9 @@ const DraggableDroppableTagRow: FC = ({ nestLevel } }) const { setNodeRef: setDropRef, isOver: over } = useDroppable ({ - id: dndId, - data: { kind: 'tag', tagId: tag.id } }) + id: dndId, + disabled: dndDisabled, + data: { kind: 'tag', tagId: tag.id } }) const activeDragging = activeDndId === dndId const style: CSSProperties = { transform: CSS.Translate.toString (transform), diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 63fbfd2..1949ec5 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -150,16 +150,17 @@ const buildFlatTagByCategory = ( byCategory: TagByCategory, ): TagByCategory => { const tagsTmp = { } as TagByCategory - const seen = new Set () + const seen = new Set () for (const category of CATEGORIES) tagsTmp[category] = [] const visit = (tag: TagWithSections) => { - if (seen.has (tag.id)) + const key = `${ tag.category }:${ tag.id }` + if (seen.has (key)) return - seen.add (tag.id) + seen.add (key) tagsTmp[tag.category].push ({ ...tag, children: [] }) for (const child of tag.children ?? []) diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 455add6..04328c2 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -86,7 +86,7 @@ const TagLink: FC = ({ tag, className={cn ( 'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline', 'gap-x-1 md:items-baseline')}> - {(linkFlg && withWiki && isFullTag (tag)) && ( + {(linkFlg && withWiki && isFullTag (tag) && tag.category !== 'nico') && ( {(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists) ? ( diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index cab0ed4..2b9822d 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -28,15 +28,18 @@ export const fetchPost = async (id: string): Promise => await apiGet (`/po export const fetchPostChanges = async ( - { post, tag, page, limit }: { - post?: string - tag?: string - page: number - limit: number }): Promise<{ + { post, tag, externalTag, page, limit }: { + post?: string + tag?: string + externalTag?: string + page: number + limit: number }): Promise<{ versions: PostVersion[] count: number }> => await apiGet ('/posts/versions', { params: { ...(post && { post }), ...(tag && { tag }), + ...(externalTag && { + external_tag: externalTag }), page, limit } }) diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 264a380..46254ea 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -128,6 +128,7 @@ const prefetchPostShow: Prefetcher = async (qc, url) => { const prefetchPostChanges: Prefetcher = async (qc, url) => { const id = url.searchParams.get ('id') const tag = url.searchParams.get ('tag') + const externalTag = url.searchParams.get ('external_tag') const page = Number (url.searchParams.get ('page') || 1) const limit = Number (url.searchParams.get ('limit') || 20) @@ -141,9 +142,11 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => { await qc.prefetchQuery ({ queryKey: postsKeys.changes ({ ...(id && { id }), ...(tag && { tag }), + ...(externalTag && { externalTag }), page, limit }), queryFn: () => fetchPostChanges ({ ...(id && { id }), ...(tag && { tag }), + ...(externalTag && { externalTag }), page, limit }) }) } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 293f849..49c231f 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -11,7 +11,11 @@ export const postsKeys = { index: (p: FetchPostsParams) => ['posts', 'index', p] as const, show: (id: string) => ['posts', id] as const, related: (id: string) => ['related', id] as const, - changes: (p: { post?: string; tag?: string; page: number; limit: number }) => + changes: (p: { post?: string + tag?: string + externalTag?: string + page: number + limit: number }) => ['posts', 'changes', p] as const } export const gekanatorKeys = { diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 71aa3c8..70b517d 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -51,6 +51,7 @@ const PostHistoryPage: FC = () => { const query = new URLSearchParams (location.search) const id = query.get ('id') const tagId = query.get ('tag') + const externalTagId = query.get ('external_tag') const page = Number (query.get ('page') ?? 1) const limit = Number (query.get ('limit') ?? 20) @@ -66,9 +67,12 @@ const PostHistoryPage: FC = () => { const { data, isLoading: loading } = useQuery ({ queryKey: postsKeys.changes ({ ...(id && { post: id }), ...(tagId && { tag: tagId }), + ...(externalTagId && { externalTag: externalTagId }), page, limit }), queryFn: () => fetchPostChanges ({ ...(id && { post: id }), ...(tagId && { tag: tagId }), + ...(externalTagId && { + externalTag: externalTagId }), page, limit }) }) const changes = data?.versions ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index 568c5d6..8dde00d 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -406,11 +406,15 @@ const TagListPage: FC = () => { {results.map (row => ( - + {row.postCount} @@ -429,7 +433,10 @@ const TagListPage: FC = () => { {dateString (row.createdAt)} {dateString (row.updatedAt)} - + 耕作履歴