From f336b2f13bf22743cadfeca38f0e428288b991a0 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 25 Sep 2026 01:15:06 +0900 Subject: [PATCH] =?UTF-8?q?=E5=A4=96=E9=83=A8=E3=82=BF=E3=82=B0=E3=82=92?= =?UTF-8?q?=E5=88=86=E9=9B=A2=20(#419)=20(#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **本番 DB のバックアップをかならずすること** Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/420 Co-authored-by: miteruzo --- .../app/controllers/nico_tags_controller.rb | 74 +++-- .../controllers/post_versions_controller.rb | 118 +++---- backend/app/controllers/posts_controller.rb | 56 ++-- .../controllers/tag_children_controller.rb | 2 - backend/app/controllers/tags_controller.rb | 288 ++++++++++++----- backend/app/models/external_tag.rb | 20 ++ backend/app/models/nico_tag_relation.rb | 24 +- backend/app/models/nico_tag_version.rb | 2 +- backend/app/models/post.rb | 37 ++- backend/app/models/post_external_tag.rb | 4 + backend/app/models/tag.rb | 39 +-- .../app/models/tag_name_sanitisation_rule.rb | 5 +- .../app/representations/external_tag_repr.rb | 20 ++ backend/app/representations/post_repr.rb | 21 +- .../app/services/nico_tag_version_recorder.rb | 15 +- backend/app/services/post_create_plan.rb | 44 ++- backend/app/services/tag_versioning.rb | 24 +- backend/app/services/version_recorder.rb | 6 +- backend/config/routes.rb | 2 +- .../20260921040000_create_external_tags.rb | 54 ++++ ...0260921050000_create_post_external_tags.rb | 29 ++ ...hange_foreign_key_on_nico_tag_relations.rb | 6 + .../20260921230000_migrate_external_tags.rb | 199 ++++++++++++ backend/db/schema.rb | 23 +- backend/lib/tasks/sync_nico.rake | 89 ++++-- backend/spec/db/external_tags_spec.rb | 191 +++++++++++ backend/spec/factories/external_tags.rb | 6 + backend/spec/factories/tags.rb | 7 - backend/spec/models/external_tag_spec.rb | 38 +++ backend/spec/models/nico_tag_relation_spec.rb | 27 ++ backend/spec/models/post_external_tag_spec.rb | 25 ++ backend/spec/models/post_spec.rb | 19 ++ backend/spec/models/tag_spec.rb | 85 ++--- backend/spec/models/version_record_spec.rb | 10 +- backend/spec/requests/nico_tags_spec.rb | 139 +++++--- backend/spec/requests/posts_spec.rb | 268 +++++++++++++++- backend/spec/requests/tag_children_spec.rb | 58 ---- backend/spec/requests/tags_spec.rb | 300 ++++++++++++++---- .../nico_tag_version_recorder_spec.rb | 76 +++++ .../spec/services/post_create_plan_spec.rb | 13 + backend/spec/tasks/nico_sync_spec.rb | 243 +++++++++++++- .../DraggableDroppableTagRow.test.tsx | 29 +- .../components/DraggableDroppableTagRow.tsx | 9 +- .../src/components/TagDetailSidebar.test.tsx | 66 ++++ frontend/src/components/TagDetailSidebar.tsx | 7 +- frontend/src/components/TagLink.test.tsx | 27 ++ frontend/src/components/TagLink.tsx | 2 +- frontend/src/components/common/PageTitle.tsx | 6 +- frontend/src/lib/posts.test.ts | 21 ++ frontend/src/lib/posts.ts | 13 +- frontend/src/lib/prefetchers.test.ts | 15 + frontend/src/lib/prefetchers.ts | 3 + frontend/src/lib/queryKeys.ts | 7 +- frontend/src/lib/tags.ts | 12 + .../src/pages/posts/PostHistoryPage.test.tsx | 63 ++++ frontend/src/pages/posts/PostHistoryPage.tsx | 23 +- frontend/src/pages/tags/TagListPage.test.tsx | 47 ++- frontend/src/pages/tags/TagListPage.tsx | 13 +- 58 files changed, 2439 insertions(+), 630 deletions(-) create mode 100644 backend/app/models/external_tag.rb create mode 100644 backend/app/models/post_external_tag.rb create mode 100644 backend/app/representations/external_tag_repr.rb create mode 100644 backend/db/migrate/20260921040000_create_external_tags.rb create mode 100644 backend/db/migrate/20260921050000_create_post_external_tags.rb create mode 100644 backend/db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations.rb create mode 100644 backend/db/migrate/20260921230000_migrate_external_tags.rb create mode 100644 backend/spec/db/external_tags_spec.rb create mode 100644 backend/spec/factories/external_tags.rb create mode 100644 backend/spec/models/external_tag_spec.rb create mode 100644 backend/spec/models/nico_tag_relation_spec.rb create mode 100644 backend/spec/models/post_external_tag_spec.rb create mode 100644 backend/spec/services/nico_tag_version_recorder_spec.rb create mode 100644 frontend/src/components/TagDetailSidebar.test.tsx create mode 100644 frontend/src/pages/posts/PostHistoryPage.test.tsx diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index ce63840..6f97dbc 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -15,30 +15,35 @@ class NicoTagsController < ApplicationController limit = 1 if limit < 1 post_tag_max_sql = - PostTag - .select('tag_id, MAX(created_at) AS max_created_at') - .group('tag_id') + PostExternalTag + .select('external_tag_id, MAX(created_at) AS max_created_at') + .group('external_tag_id') .to_sql - q = Tag.nico_tags - .joins(:tag_name) - .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ - 'ON post_tag_max.tag_id = tags.id') - .includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page }) - q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name + q = + ExternalTag + .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ + 'ON post_tag_max.external_tag_id = external_tags.id') + .includes(linked_tags: { tag_name: :wiki_page }) + if name + q = q.where(('external_tags.name LIKE ? ' + + "OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"), + "%#{ name }%", "%#{ name }") + end + if linked_tag linked_tag_ids = - Tag - .joins(:tag_name) - .where('tag_names.name LIKE ?', "%#{ linked_tag }%") - .pluck(:id) + Tag + .joins(:tag_name) + .where('tag_names.name LIKE ?', "%#{ linked_tag }%") + .pluck(:id) linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id) q = q.where(id: linked_nico_tag_ids) end if link_status.in?(['linked', 'unlinked']) exists_sql = 'EXISTS (SELECT 1 FROM nico_tag_relations ' \ - 'WHERE nico_tag_relations.nico_tag_id = tags.id)' + 'WHERE nico_tag_relations.nico_tag_id = external_tags.id)' q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }") end @@ -46,34 +51,39 @@ class NicoTagsController < ApplicationController sort_sql = case order[0] when 'name' - 'tag_names.name' + 'external_tags.name' when 'updated_at' 'post_tag_max.max_created_at' else - "tags.#{ order[0] }" + "external_tags.#{ order[0] }" end - tags = q.reselect('tags.*', + tags = q.reselect('external_tags.*', Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at')) - .order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }")) + .order(Arel.sql("#{ sort_sql } #{ order[1] }, external_tags.id #{ order[1] }")) .limit(limit) .offset((page - 1) * limit) .to_a render json: { tags: tags.map { |tag| - TagRepr.base(tag).merge( + external_tag_json(tag).merge( recent_post_tag_created_at: tag.recent_post_tag_created_at, linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) }) }, count: } end + def show + tag = ExternalTag.find(params[:id]) + + render json: external_tag_json(tag) + end + def update return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? id = params[:id].to_i - tag = Tag.find(id) - return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico? + tag = ExternalTag.find(id) linked_tag_names = params[:tags].to_s.split linked_tags = nil @@ -81,16 +91,15 @@ class NicoTagsController < ApplicationController ApplicationRecord.transaction do linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false, with_no_deerjikist: false) - if linked_tags.any? { |t| t.nico? } - raise Tag::NicoTagNormalisationError - end TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) tag.linked_tags = linked_tags tag.save! - NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) + NicoTagVersionRecorder.record!(external_tag: tag, + event_type: :update, + created_by_user: current_user) end render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok @@ -102,6 +111,21 @@ class NicoTagsController < ApplicationController private + def external_tag_json tag + { id: tag.id, + name: "#{ tag.platform }:#{ tag.name }", + category: 'nico', + post_count: tag.post_count, + created_at: tag.created_at, + updated_at: tag.created_at, + deprecated_at: nil, + aliases: [], + parents: [], + has_wiki: false, + material_id: nil, + has_deerjikists: false } + end + def render_nico_tag_form_record_invalid record if record.is_a?(TagName) || record.is_a?(Tag) render_validation_error fields: { tags: record.errors.full_messages.map { |message| diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index e938a11..0ce1670 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -2,6 +2,9 @@ class PostVersionsController < ApplicationController def index post_id = params[:post].presence tag_id = params[:tag].presence&.to_i + external_tag_id = params[:external_tag].presence&.to_i + return head :bad_request if tag_id && external_tag_id + page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i @@ -26,9 +29,14 @@ class PostVersionsController < ApplicationController 'prev.original_created_from AS prev_original_created_from', 'prev.original_created_before AS prev_original_created_before') q = q.where('post_versions.post_id = ?', post_id) if post_id - if tag_id - q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('id', #{ tag_id })) " + - "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', #{ tag_id }))") + if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id))) + q = q.where('JSON_CONTAINS(post_versions.tags_json,' + + "JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " + + 'OR JSON_CONTAINS(prev.tags_json,' + + "JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id }))") + elsif tag_id + q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', #{ tag_id })) " + + "OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', #{ tag_id }))") end count = q.except(:select, :order, :limit, :offset).count @@ -43,50 +51,19 @@ class PostVersionsController < ApplicationController private def serialise_versions rows + rows = rows.to_a user_ids = rows.map(&:created_by_user_id).compact.uniq users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h + snapshots = rows.flat_map { |row| + [normalise_json(row.tags_json), + normalise_json(row.attributes['prev_tags_json']) || []] + } + external_tag_names = external_tag_names_for(snapshots) rows.map do |row| - cur_tags = - normalise_json(row.tags_json) - .sort_by { [(case _1.fetch('category') - when 'deerjikist' - 0 - when 'meme' - 1 - when 'character' - 2 - when 'general' - 3 - when 'material' - 4 - when 'meta' - 5 - else - 6 - end), - _1.fetch('name').downcase] } - .map { Post.tag_snapshot_literal(_1) } - prev_tags = - (normalise_json(row.attributes['prev_tags_json']) || []) - .sort_by { [(case _1.fetch('category') - when 'deerjikist' - 0 - when 'meme' - 1 - when 'character' - 2 - when 'general' - 3 - when 'material' - 4 - when 'meta' - 5 - else - 6 - end), - _1.fetch('name').downcase] } - .map { Post.tag_snapshot_literal(_1) } + cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names) + prev_tags = snapshot_tag_literals( + normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names) { post_id: row.post_id, version_no: row.version_no, @@ -113,21 +90,52 @@ class PostVersionsController < ApplicationController end end - def build_version_tags(cur_tags, prev_tags) + def external_tag_names_for snapshots + ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq + names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name| + [id, "#{ platform }:#{ name }"] + } + missing_ids = ids - names.keys + + NicoTagVersion + .where(tag_id: missing_ids) + .order(:tag_id, version_no: :desc) + .pluck(:tag_id, :name) + .each { |id, name| names[id] ||= name } + + names + end + + def snapshot_tag_literals snapshots, external_tag_names + snapshots + .filter_map { |snapshot| + if snapshot.key?('tag_id') + [tag_category_order(snapshot['category']), + Post.tag_snapshot_literal(snapshot)] + elsif external_tag_names[snapshot['external_tag_id']] + [6, external_tag_names[snapshot['external_tag_id']]] + end + } + .sort_by { |order, name| [order, name.downcase] } + .map(&:second) + end + + def tag_category_order category + ['deerjikist', 'meme', 'character', 'general', 'material', 'meta'].index(category) || 6 + end + + def build_version_tags cur_tags, prev_tags (cur_tags | prev_tags).map do |name| type = - if cur_tags.include?(name) && prev_tags.include?(name) - 'context' - elsif cur_tags.include?(name) - 'added' - else - 'removed' - end + if cur_tags.include?(name) && prev_tags.include?(name) + 'context' + elsif cur_tags.include?(name) + 'added' + else + 'removed' + end - { - name:, - type: - } + { name:, type: } end end end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index edbc392..b9023b0 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -49,7 +49,7 @@ class PostsController < ApplicationController filtered_posts .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) - .preload(:uploaded_user, :parents, :children, + .preload(:external_tags, :uploaded_user, :parents, :children, post_tags: [:sections, { tag: [:deerjikists, :materials, { tag_name: :wiki_page }] }]) .with_attached_thumbnail @@ -102,12 +102,14 @@ class PostsController < ApplicationController end def random - post = filtered_posts.preload(:uploaded_user, :parents, :children, - post_tags: [:sections, { tag: [:deerjikists, :materials, - { tag_name: :wiki_page }] }]) - .with_attached_thumbnail - .order('RAND()') - .first + post = + filtered_posts + .preload(:uploaded_user, :parents, :children, + post_tags: [:sections, { tag: [:deerjikists, :materials, + { tag_name: :wiki_page }] }]) + .with_attached_thumbnail + .order('RAND()') + .first return head :not_found unless post render json: PostRepr.base(post, current_user) @@ -426,8 +428,20 @@ class PostsController < ApplicationController end end - def tagged_post_ids_for(name) = - Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id) + def tagged_post_ids_for(name) + posts_by_internal_tags = + Post + .joins(tags: :tag_name) + .where(tag_names: { name: }) + .select(:id) + + posts_by_external_tags = + Post + .joins(:external_tags) + .where("CONCAT(external_tags.platform, ':', external_tags.name) = ?", name) + + (posts_by_internal_tags + posts_by_external_tags).uniq(&:id) + end def sync_post_tags! post, desired_tags, sections desired_tags.each do |t| @@ -505,7 +519,10 @@ class PostsController < ApplicationController memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:) end - root_ids.filter_map { |id| build_node.call(id, []) } + internal_tags = root_ids.filter_map { |id| build_node.call(id, []) } + external_tags = + post.external_tags.map { ExternalTagRepr.inline(_1).merge(children: [], sections: []) } + internal_tags + external_tags end def sibling_posts_by_parent parent_post_ids @@ -653,7 +670,7 @@ class PostsController < ApplicationController def editable_tag_names_from_version version version.tags_json - .reject { _1.fetch('category') == 'nico' } + .select { _1.key?('tag_id') } .map { Post.tag_snapshot_literal(_1) } .sort end @@ -671,7 +688,6 @@ class PostsController < ApplicationController post .post_tags .joins(tag: :tag_name) - .merge(Tag.not_nico) .merge(Tag.where(deprecated_at: nil)) .includes(:sections, tag: :tag_name) .order('tag_names.name') @@ -689,8 +705,7 @@ class PostsController < ApplicationController tag_names:, video_ms_param:, duration_param:, parent_post_ids: validate_original_created_values!(original_created_from, original_created_before) Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true, - with_sections: true) => - { tags:, sections: } + with_sections: true) => { tags:, sections: } tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:) @@ -833,15 +848,12 @@ class PostsController < ApplicationController original_created_from: snapshot[:original_created_from], original_created_before: snapshot[:original_created_before]) - Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, - deny_deprecated: true, - with_sections: true) => - { tags: editable_tags, sections: } - TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) + Tag.normalise_tags!(snapshot[:tag_names], + with_tagme: false, + deny_deprecated: true, + with_sections: true) => { tags:, sections: } + TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) - readonly_tags = post.tags.nico.to_a - - tags = readonly_tags + editable_tags tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil diff --git a/backend/app/controllers/tag_children_controller.rb b/backend/app/controllers/tag_children_controller.rb index cfdde30..ebffad7 100644 --- a/backend/app/controllers/tag_children_controller.rb +++ b/backend/app/controllers/tag_children_controller.rb @@ -10,7 +10,6 @@ class TagChildrenController < ApplicationController parent = Tag.find(parent_id) child = Tag.find(child_id) - return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico? ApplicationRecord.transaction do TagVersioning.ensure_snapshot!(child, created_by_user: current_user) @@ -33,7 +32,6 @@ class TagChildrenController < ApplicationController parent = Tag.find(parent_id) child = Tag.find(child_id) - return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico? ApplicationRecord.transaction do TagVersioning.ensure_snapshot!(child, created_by_user: current_user) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index f152d19..dae8994 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -34,49 +34,155 @@ class TagsController < ApplicationController offset = (page - 1) * limit - q = + tags = if post_id.present? - Tag.joins(:posts, :tag_name) + Tag.joins(:posts, :tag_name).where(posts: { id: post_id }) else Tag.joins(:tag_name) end - .includes(:tag_name, :materials, tag_name: :wiki_page) - q = q.where(posts: { id: post_id }) if post_id.present? - q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name - q = q.where(category:) if category - q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0] - q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1] - q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0] - q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1] - q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0] - q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1] - if deprecated_given - q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil) + external_tags = + if post_id.present? + ExternalTag.joins(:posts).where(posts: { id: post_id }) + else + ExternalTag.all + end + + if name + tags = tags.where('tag_names.name LIKE ?', "%#{ name }%") + external_tags = + external_tags.where("CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?", + "%#{ name }%") end + if category == 'nico' + tags = tags.none + elsif category + tags = tags.where(category:) + external_tags = external_tags.none + end + + if post_count_between[0] + tags = tags.where('tags.post_count >= ?', post_count_between[0]) + external_tags = external_tags.where('external_tags.post_count >= ?', post_count_between[0]) + end + + if post_count_between[1] + tags = tags.where('tags.post_count <= ?', post_count_between[1]) + external_tags = external_tags.where('external_tags.post_count <= ?', post_count_between[1]) + end + + if created_between[0] + tags = tags.where('tags.created_at >= ?', created_between[0]) + external_tags = external_tags.where('external_tags.created_at >= ?', created_between[0]) + end + + if created_between[1] + tags = tags.where('tags.created_at <= ?', created_between[1]) + external_tags = external_tags.where('external_tags.created_at <= ?', created_between[1]) + end + + if updated_between[0] + tags = tags.where('tags.updated_at >= ?', updated_between[0]) + external_tags = external_tags.where('external_tags.created_at >= ?', updated_between[0]) + end + + if updated_between[1] + tags = tags.where('tags.updated_at <= ?', updated_between[1]) + external_tags = external_tags.where('external_tags.created_at <= ?', updated_between[1]) + end + + if deprecated_given + if deprecated + tags = tags.where.not(deprecated_at: nil) + external_tags = external_tags.none + else + tags = tags.where(deprecated_at: nil) + end + end + + tag_sql = tags.select("'tag' AS source", + 'tags.id', + 'tag_names.name', + 'tags.category', + 'tags.post_count', + 'tags.created_at', + 'tags.updated_at', + 'tags.deprecated_at').to_sql + + external_tag_sql = external_tags.select( + "'external_tag' AS source", + 'external_tags.id', + "CONCAT(external_tags.platform, ':', external_tags.name) AS name", + "'nico' AS category", + 'external_tags.post_count', + 'external_tags.created_at', + 'external_tags.created_at AS updated_at', + 'NULL AS deprecated_at').to_sql + + union_sql = "#{ tag_sql } UNION ALL #{ external_tag_sql }" + sort_sql = - case order[0] - when 'name' - 'tag_names.name' - when 'category' - 'CASE tags.category ' + + if order[0] == 'category' + 'CASE category ' + "WHEN 'deerjikist' THEN 0 " + "WHEN 'meme' THEN 1 " + "WHEN 'character' THEN 2 " + "WHEN 'general' THEN 3 " + "WHEN 'material' THEN 4 " + "WHEN 'meta' THEN 5 " + - "WHEN 'nico' THEN 6 END" + "WHEN 'nico' THEN 6 " + + 'END' else - "tags.#{ order[0] }" + order[0] end - tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }")) - .limit(limit) - .offset(offset) - .to_a - render json: { tags: TagRepr.many(tags), count: q.size } + connection = ApplicationRecord.connection + + count = connection.select_value(<<~SQL) + SELECT + COUNT(0) + FROM + (#{ union_sql }) legacy_tags + SQL + + rows = connection.select_all(<<~SQL).to_a + SELECT + source + , id + FROM + (#{ union_sql }) legacy_tags + ORDER BY + #{ sort_sql } #{ order[1] } + , id #{ order[1] } + , source #{ order[1] } + LIMIT + #{ limit } + OFFSET + #{ offset } + SQL + + tag_ids = rows.filter { _1['source'] == 'tag' }.map { _1['id'] } + external_tag_ids = rows.filter { _1['source'] == 'external_tag' }.map { _1['id'] } + + tags_by_id = + Tag + .joins(:tag_name) + .includes(:tag_name, :materials, tag_name: :wiki_page) + .where(id: tag_ids) + .index_by(&:id) + external_tags_by_id = + ExternalTag + .where(id: external_tag_ids) + .index_by(&:id) + + render json: { tags: rows.map { |row| + if row['source'] == 'tag' + TagRepr.base(tags_by_id.fetch(row['id'])) + else + external_tag_json(external_tags_by_id.fetch(row['id'])) + end + }, count: } end def with_depth @@ -100,13 +206,14 @@ class TagsController < ApplicationController def autocomplete q = params[:q].to_s.strip.sub(/\Anot:/i, '') + prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%" with_nico = bool?(:nico, default: true) present_only = bool?(:present, default: true) alias_rows = TagName - .where('name LIKE ?', "#{ q }%") + .where('name LIKE ?', prefix) .where.not(canonical_id: nil) .pluck(:canonical_id, :name) @@ -118,54 +225,77 @@ class TagsController < ApplicationController matched_alias_by_tag_name_id[canonical_id] ||= alias_name end - base = Tag.joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .where(deprecated_at: nil) + base = + Tag + .joins(:tag_name) + .includes(:tag_name, :materials, tag_name: :wiki_page) + .where(deprecated_at: nil) base = base.where('tags.post_count > 0') if present_only - canonical_hit = - base - .where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') + - 'tag_names.name LIKE ?'), - *(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%") + canonical_hit = base.where('tag_names.name LIKE ?', prefix) - tags = - if canonical_ids.present? - canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq)) - else - canonical_hit - end + internal_tags = canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq)) - tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a + internal_rows = + internal_tags + .order(Arel.sql('tags.post_count DESC, tag_names.name')) + .limit(20) + .map { |tag| + TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id]) + } - render json: tags.map { |tag| - TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id]) - } + return render json: internal_rows unless with_nico + + external_base = ExternalTag.all + external_base = external_base.where('post_count > 0') if present_only + + external_rows = + external_base + .where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", prefix, prefix) + .order(post_count: :desc, name: :asc) + .limit(20) + .map { external_tag_json(_1) } + + rows = + (internal_rows + external_rows) + .sort_by { |row| [-row['post_count'], row['name']] } + .first(20) + + render json: rows end def show - tag = Tag.joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .find_by(id: params[:id]) - if tag - render json: TagRepr.base(tag) - else - head :not_found - end + tag = + Tag + .joins(:tag_name) + .includes(:tag_name, :materials, tag_name: :wiki_page) + .find_by(id: params[:id]) + return render json: TagRepr.base(tag) if tag + + external_tag = ExternalTag.find_by(id: params[:id]) + return render json: ExternalTagRepr.base(external_tag) if external_tag + + head :not_found end def show_by_name name = params[:name].to_s.strip return render_bad_request('name は必須です.') if name.blank? - tag = Tag.joins(:tag_name) - .includes(:tag_name, :materials, tag_name: :wiki_page) - .find_by(tag_names: { name: }) - if tag - render json: TagRepr.base(tag) - else - head :not_found - end + tag = + Tag + .joins(:tag_name) + .includes(:tag_name, :materials, tag_name: :wiki_page) + .find_by(tag_names: { name: }) + return render json: TagRepr.base(tag) if tag + + platform, external_name = name.split(':', 2) + return head :not_found unless external_name + + external_tag = ExternalTag.find_by(platform:, name: external_name) + return head :not_found unless external_tag + + render json: ExternalTagRepr.base(external_tag) end def deerjikists @@ -282,11 +412,7 @@ class TagsController < ApplicationController parent_names = params[:parent_tags].to_s.split.uniq deprecated = bool?(:deprecated) - if tag.nico? && deprecated - return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated - end - - if tag.nico? || category == 'nico' + if category == 'nico' return render_unprocessable_entity 'ニコタグは変更できません.', field: :category end @@ -334,13 +460,9 @@ class TagsController < ApplicationController tag = Tag.find(params[:id]) - if tag.nico? && deprecated_given && deprecated - return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated - end - return unless validate_tag_rename(tag, name) - if tag.nico? || (category.present? && category == 'nico') + if category.present? && category == 'nico' return render_unprocessable_entity 'ニコタグは変更できません.', field: :category end @@ -547,11 +669,6 @@ class TagsController < ApplicationController end def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil - if tag.nico? - NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) - return - end - TagVersionRecorder.record!(tag:, event_type:, created_by_user:) return unless name_changed @@ -661,8 +778,7 @@ class TagsController < ApplicationController def update_parent_tags! tag, parent_names parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false, - with_no_deerjikist: false, - deny_nico: true) + with_no_deerjikist: false) old_parent_tags = tag.parents.to_a @@ -790,4 +906,20 @@ class TagsController < ApplicationController render_validation_error fields: end + + def external_tag_json tag + { 'id' => tag.id, + 'name' => "#{ tag.platform }:#{ tag.name }", + 'category' => 'nico', + 'deprecated_at' => nil, + 'created_at' => tag.created_at, + 'updated_at' => tag.created_at, + 'post_count' => tag.post_count, + 'matched_alias' => nil, + 'aliases' => [], + 'parents' => [], + 'has_wiki' => false, + 'material_id' => nil, + 'has_deerjikists' => false } + end end diff --git a/backend/app/models/external_tag.rb b/backend/app/models/external_tag.rb new file mode 100644 index 0000000..f49ec54 --- /dev/null +++ b/backend/app/models/external_tag.rb @@ -0,0 +1,20 @@ +class ExternalTag < ApplicationRecord + enum :platform, nico: 'nico' + validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys } + + has_many :post_external_tags, dependent: :delete_all + has_many :posts, through: :post_external_tags + + has_many :nico_tag_versions, foreign_key: :tag_id, inverse_of: :external_tag + + has_many :nico_tag_relations, + foreign_key: :nico_tag_id, + inverse_of: :nico_tag, + dependent: :destroy + + has_many :linked_tags, through: :nico_tag_relations, source: :tag + + def snapshot_linked_tag_names + linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + end +end diff --git a/backend/app/models/nico_tag_relation.rb b/backend/app/models/nico_tag_relation.rb index d2c4a82..443937f 100644 --- a/backend/app/models/nico_tag_relation.rb +++ b/backend/app/models/nico_tag_relation.rb @@ -1,24 +1,10 @@ class NicoTagRelation < ApplicationRecord - belongs_to :nico_tag, class_name: 'Tag' - belongs_to :tag, class_name: 'Tag' + belongs_to :nico_tag, + class_name: 'ExternalTag', + foreign_key: :nico_tag_id, + inverse_of: :nico_tag_relations + belongs_to :tag, class_name: 'Tag', foreign_key: :tag_id validates :nico_tag_id, presence: true validates :tag_id, presence: true - - validate :nico_tag_must_be_nico - validate :tag_mustnt_be_nico - - private - - def nico_tag_must_be_nico - if nico_tag && nico_tag.category != 'nico' - errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.' - end - end - - def tag_mustnt_be_nico - if tag && tag.category == 'nico' - errors.add :tag_id, '連携先タグのカテゴリはニコニコであってはなりません.' - end - end end diff --git a/backend/app/models/nico_tag_version.rb b/backend/app/models/nico_tag_version.rb index b57252a..e83ecff 100644 --- a/backend/app/models/nico_tag_version.rb +++ b/backend/app/models/nico_tag_version.rb @@ -1,7 +1,7 @@ class NicoTagVersion < ApplicationRecord include VersionRecord - belongs_to :tag + belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions validates :name, presence: true end diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index e4fd44c..805e91d 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -92,6 +92,9 @@ class Post < ApplicationRecord inverse_of: :parent_post has_many :children, through: :child_post_implications, source: :post + has_many :post_external_tags, dependent: :destroy + has_many :external_tags, through: :post_external_tags + has_one_attached :thumbnail attribute :version_no, :integer, default: 1 @@ -150,19 +153,27 @@ class Post < ApplicationRecord end def snapshot_tags_json - post_tags - .joins(tag: :tag_name) - .includes(:sections, tag: :tag_name) - .order('tags.id') - .map do |pt| - { 'id' => pt.tag.id, - 'version_no' => pt.tag.version_no, - 'name' => pt.tag.name, - 'category' => pt.tag.category, - 'sections' => pt.sections.sort_by(&:begin_ms).map { - { 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms } - } } - end + tag_snapshots = + post_tags + .joins(tag: :tag_name) + .includes(:sections, tag: :tag_name) + .order('tags.id') + .map { |pt| + { 'tag_id' => pt.tag.id, + 'version_no' => pt.tag.version_no, + 'name' => pt.tag.name, + 'category' => pt.tag.category, + 'sections' => pt.sections.sort_by(&:begin_ms).map { + { 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms } + } } + } + + external_tag_snapshots = + post_external_tags.order(:external_tag_id).map { + { 'external_tag_id' => _1.external_tag_id } + } + + tag_snapshots + external_tag_snapshots end def self.section_literal section diff --git a/backend/app/models/post_external_tag.rb b/backend/app/models/post_external_tag.rb new file mode 100644 index 0000000..1aa4d7e --- /dev/null +++ b/backend/app/models/post_external_tag.rb @@ -0,0 +1,4 @@ +class PostExternalTag < ApplicationRecord + belongs_to :post + belongs_to :external_tag, counter_cache: :post_count +end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 8a09ef4..02346d4 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -28,11 +28,10 @@ class Tag < ApplicationRecord has_many :post_tags, inverse_of: :tag has_many :posts, through: :post_tags - has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy - has_many :linked_tags, through: :nico_tag_relations, source: :tag - has_many :reversed_nico_tag_relations, - class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy + class_name: 'NicoTagRelation', + foreign_key: :tag_id, + dependent: :destroy has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy @@ -50,7 +49,6 @@ class Tag < ApplicationRecord has_many :materials has_many :tag_versions - has_many :nico_tag_versions belongs_to :tag_name delegate :wiki_page, to: :tag_name @@ -65,17 +63,13 @@ class Tag < ApplicationRecord character: 'character', general: 'general', material: 'material', - nico: 'nico', meta: 'meta' validates :category, presence: true, inclusion: { in: Tag.categories.keys } - validate :nico_tag_name_must_start_with_nico + validate :tag_name_mustnt_start_with_nico validate :tag_name_must_be_canonical validate :category_must_be_deerjikist_with_deerjikists - validate :nico_tags_cannot_be_deprecated - - scope :nico_tags, -> { nico } CATEGORY_PREFIXES = { 'general:' => :general, @@ -110,10 +104,9 @@ class Tag < ApplicationRecord def self.normalise_tags! tag_names, with_tagme: true, with_no_deerjikist: true, - deny_nico: true, deny_deprecated: false, with_sections: false - if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } + if tag_names.any? { |n| n.downcase.start_with?('nico:') } raise NicoTagNormalisationError end @@ -273,12 +266,8 @@ class Tag < ApplicationRecord TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) source_tag.destroy! - if source_tag.nico? - source_tag_name.destroy! - else - source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, - updated_at: Time.current) - end + source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, + updated_at: Time.current) TagVersioning.record!(target_tag, event_type: :update, created_by_user:) end @@ -305,11 +294,9 @@ class Tag < ApplicationRecord private - def nico_tag_name_must_start_with_nico - n = name.to_s - if ((nico? && !(n.downcase.start_with?('nico:'))) || - (!(nico?) && n.downcase.start_with?('nico:'))) - errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.' + def tag_name_mustnt_start_with_nico + if name.to_s.downcase.start_with?('nico:') + errors.add :name, 'タグの命名規則に反してゐます.' end end @@ -350,10 +337,4 @@ class Tag < ApplicationRecord total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i end - - def nico_tags_cannot_be_deprecated - if nico? && deprecated_at.present? - errors.add :deprecated_at, 'ニコタグは廃止できません.' - end - end end 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/app/representations/external_tag_repr.rb b/backend/app/representations/external_tag_repr.rb new file mode 100644 index 0000000..d7e94f8 --- /dev/null +++ b/backend/app/representations/external_tag_repr.rb @@ -0,0 +1,20 @@ +module ExternalTagRepr + module_function + + def base tag + { 'id' => tag.id, + 'name' => "#{ tag.platform }:#{ tag.name }", + 'category' => 'nico', + 'post_count' => tag.post_count, + 'created_at' => tag.created_at, + 'updated_at' => tag.created_at, + 'deprecated_at' => nil, + 'aliases' => [], + 'parents' => [], + 'has_wiki' => false, + 'material_id' => nil, + 'has_deerjikists' => false } + end + + def inline(tag) = base(tag) +end diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index 79a1b1d..01206e4 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -87,15 +87,24 @@ module PostRepr end def tag_json post - post + internal_tags = + post .post_tags .reject { _1.tag.deprecated? } .sort_by { _1.tag.name } - .map do |post_tag| - TagRepr.inline(post_tag.tag).merge( - 'children' => [], - 'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms])) - end + .map { |post_tag| + TagRepr.inline(post_tag.tag).merge( + 'children' => [], + 'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms])) + } + + external_tags = + post + .external_tags + .sort_by { _1.name } + .map { ExternalTagRepr.base(_1).merge('children' => [], 'sections' => []) } + + internal_tags + external_tags end def thumbnail_url post, host: nil diff --git a/backend/app/services/nico_tag_version_recorder.rb b/backend/app/services/nico_tag_version_recorder.rb index 8f1be7f..149fb17 100644 --- a/backend/app/services/nico_tag_version_recorder.rb +++ b/backend/app/services/nico_tag_version_recorder.rb @@ -1,19 +1,22 @@ class NicoTagVersionRecorder < VersionRecorder - def self.record! tag:, event_type:, created_by_user: - new(tag:, event_type:, created_by_user:).record! + def self.record! external_tag:, event_type:, created_by_user: + new(external_tag:, event_type:, created_by_user:).record! end - def initialize tag:, event_type:, created_by_user: - super(record: tag, event_type:, created_by_user:) + def initialize external_tag:, event_type:, created_by_user: + super(record: external_tag, event_type:, created_by_user:) end private def version_class = NicoTagVersion def version_association = :nico_tag_versions - def record_key = :tag + def record_key = :external_tag def snapshot_attributes - { name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') } + { name: "#{ @record.platform }:#{ @record.name }", + linked_tags: @record.snapshot_linked_tag_names.join(' ') } end + + def tracks_version_no_on_record? = false end diff --git a/backend/app/services/post_create_plan.rb b/backend/app/services/post_create_plan.rb index a6f38b7..fedd574 100644 --- a/backend/app/services/post_create_plan.rb +++ b/backend/app/services/post_create_plan.rb @@ -20,23 +20,22 @@ class PostCreatePlan parent_post_ids = normalise_parent_post_ids validate_parent_post_ids!(parent_post_ids) - { - url: @attributes[:url], - title: @attributes[:title].to_s, - thumbnail_base: @attributes[:thumbnail_base].presence, - original_created_from: @attributes[:original_created_from].presence, - original_created_before: @attributes[:original_created_before].presence, - tags: serialised_tags(direct_tag_specs, tag_sections), - display_tags: display_tags(direct_tag_specs, tag_sections), - duration: @attributes[:duration].to_s, - video_ms: video_ms, - parent_post_ids: parent_post_ids.join(' '), - direct_tag_specs: direct_tag_specs, - default_tag_specs: default_tag_specs, - snapshot_tag_specs: snapshot_tag_specs, - post_tag_specs: post_tag_specs, - tag_sections: tag_sections, - normalised_parent_post_ids: parent_post_ids } + { url: @attributes[:url], + title: @attributes[:title].to_s, + thumbnail_base: @attributes[:thumbnail_base].presence, + original_created_from: @attributes[:original_created_from].presence, + original_created_before: @attributes[:original_created_before].presence, + tags: serialised_tags(direct_tag_specs, tag_sections), + display_tags: display_tags(direct_tag_specs, tag_sections), + duration: @attributes[:duration].to_s, + video_ms: video_ms, + parent_post_ids: parent_post_ids.join(' '), + direct_tag_specs: direct_tag_specs, + default_tag_specs: default_tag_specs, + snapshot_tag_specs: snapshot_tag_specs, + post_tag_specs: post_tag_specs, + tag_sections: tag_sections, + normalised_parent_post_ids: parent_post_ids } end private @@ -50,7 +49,6 @@ class PostCreatePlan tag_names.each do |raw_name| tag_name, category, sections = parse_raw_tag_name(raw_name) existing_tag = existing_tags_by_name[tag_name] - raise Tag::NicoTagNormalisationError if existing_tag&.nico? raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated? direct_tag_specs << { @@ -139,14 +137,14 @@ class PostCreatePlan Tag.expand_parent_tags(existing_snapshot_tags) .reject(&:deprecated?) .map { |tag| - { - name: tag.name, - category: tag.category.to_sym } } + { name: tag.name, + category: tag.category.to_sym } + } merge_tag_specs(snapshot_tag_specs + expanded_parent_specs) end def merge_tag_specs specs - specs.each_with_object({ }) do |spec, merged| + specs.each_with_object({ }) { |spec, merged| merged[spec[:name]] = if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general merged[spec[:name]] @@ -155,7 +153,7 @@ class PostCreatePlan name: spec[:name], category: spec[:category] } end - end.values.sort_by { _1[:name] } + }.values.sort_by { _1[:name] } end def existing_tags_by_name diff --git a/backend/app/services/tag_versioning.rb b/backend/app/services/tag_versioning.rb index ae5b3dd..d874a3e 100644 --- a/backend/app/services/tag_versioning.rb +++ b/backend/app/services/tag_versioning.rb @@ -1,32 +1,16 @@ class TagVersioning def self.record! tag, event_type:, created_by_user: - if tag.nico? - NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) - else - TagVersionRecorder.record!(tag:, event_type:, created_by_user:) - end + TagVersionRecorder.record!(tag:, event_type:, created_by_user:) end def self.ensure_snapshot! tag, created_by_user: - if tag.nico? - return if tag.nico_tag_versions.exists? + return if tag.tag_versions.exists? - NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:) - else - return if tag.tag_versions.exists? - - TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:) - end + TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:) end def self.record_tag_snapshot! tag, created_by_user: - event_type = - if tag.nico? - tag.nico_tag_versions.exists? ? :update : :create - else - tag.tag_versions.exists? ? :update : :create - end - + event_type = tag.tag_versions.exists? ? :update : :create record!(tag, event_type:, created_by_user:) end diff --git a/backend/app/services/version_recorder.rb b/backend/app/services/version_recorder.rb index e289b61..32ab7ca 100644 --- a/backend/app/services/version_recorder.rb +++ b/backend/app/services/version_recorder.rb @@ -47,10 +47,14 @@ class VersionRecorder end def update_record_version_no! version_no + return unless tracks_version_no_on_record? + @record.update_columns(version_no:) @record.version_no = version_no end + def tracks_version_no_on_record? = true + def validate_version_sequence! latest if !(latest) && @event_type != 'create' raise "#{ version_class.name } first event must be create" @@ -60,7 +64,7 @@ class VersionRecorder raise "#{ version_class.name } create event already exists" end - return unless latest + return if !(latest) || !(tracks_version_no_on_record?) if @record.version_no != latest.version_no raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " + diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 00fc425..0f721d1 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,5 +1,5 @@ Rails.application.routes.draw do - resources :nico_tags, path: 'tags/nico', only: [:index, :update] + resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] scope 'tags/:parent_id/children', controller: :tag_children do post ':child_id', action: :create diff --git a/backend/db/migrate/20260921040000_create_external_tags.rb b/backend/db/migrate/20260921040000_create_external_tags.rb new file mode 100644 index 0000000..8afecaa --- /dev/null +++ b/backend/db/migrate/20260921040000_create_external_tags.rb @@ -0,0 +1,54 @@ +class CreateExternalTags < ActiveRecord::Migration[8.0] + def up + create_table :external_tags do |t| + t.string :platform, limit: 16, null: false + t.string :name, limit: 255, null: false + t.integer :post_count, null: false, default: 0 + t.datetime :created_at, null: false + + t.index [:platform, :name], unique: true + end + + execute <<~SQL + INSERT INTO + external_tags(id, platform, name, post_count, created_at) + SELECT + t.id + , 'nico' AS platform + , SUBSTR(tn.name, 6) AS name + , t.post_count + , t.created_at + FROM + tags t + INNER JOIN + tag_names tn + ON + tn.id = t.tag_name_id + AND t.category = 'nico' + SQL + + execute <<~SQL + INSERT INTO + external_tags(id, platform, name, post_count, created_at) + SELECT + ntv.tag_id + , 'nico' AS platform + , SUBSTR(ntv.name, 6) AS name + , 0 AS post_count + , ntv.created_at + FROM + nico_tag_versions ntv + LEFT JOIN + external_tags et + ON + et.id = ntv.tag_id + WHERE + ntv.version_no = 1 + AND et.id IS NULL + SQL + end + + def down + drop_table :external_tags + end +end diff --git a/backend/db/migrate/20260921050000_create_post_external_tags.rb b/backend/db/migrate/20260921050000_create_post_external_tags.rb new file mode 100644 index 0000000..3b2efc5 --- /dev/null +++ b/backend/db/migrate/20260921050000_create_post_external_tags.rb @@ -0,0 +1,29 @@ +class CreatePostExternalTags < ActiveRecord::Migration[8.0] + def up + create_table :post_external_tags, primary_key: [:post_id, :external_tag_id] do |t| + t.references :post, null: false, index: false, foreign_key: true + t.references :external_tag, null: false, foreign_key: true + t.datetime :created_at, null: false + end + + execute <<~SQL + INSERT INTO + post_external_tags(post_id, external_tag_id, created_at) + SELECT + pt.post_id + , pt.tag_id AS external_tag_id + , pt.created_at + FROM + post_tags pt + INNER JOIN + tags t + ON + pt.tag_id = t.id + AND t.category = 'nico' + SQL + end + + def down + drop_table :post_external_tags + end +end diff --git a/backend/db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations.rb b/backend/db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations.rb new file mode 100644 index 0000000..ac24e0b --- /dev/null +++ b/backend/db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations.rb @@ -0,0 +1,6 @@ +class ChangeForeignKeyOnNicoTagRelations < ActiveRecord::Migration[8.0] + def change + remove_foreign_key :nico_tag_relations, :tags, column: :nico_tag_id + add_foreign_key :nico_tag_relations, :external_tags, column: :nico_tag_id + end +end diff --git a/backend/db/migrate/20260921230000_migrate_external_tags.rb b/backend/db/migrate/20260921230000_migrate_external_tags.rb new file mode 100644 index 0000000..f410d67 --- /dev/null +++ b/backend/db/migrate/20260921230000_migrate_external_tags.rb @@ -0,0 +1,199 @@ +class MigrateExternalTags < ActiveRecord::Migration[8.0] + class MigrationPostVersion < ActiveRecord::Base + self.table_name = 'post_versions' + end + + def up + x = connection.select_value(<<~SQL) + SELECT + COUNT(0) + FROM + post_tag_sections pts + INNER JOIN + tags t + ON + t.id = pts.tag_id + WHERE + t.category = 'nico' + SQL + if x > 0 + raise "post_tag_sections に #{ x } 件のチンカスがあります!" + end + + x = connection.select_value(<<~SQL) + SELECT + COUNT(0) + FROM + materials m + INNER JOIN + tags t + ON + t.id = m.tag_id + WHERE + t.category = 'nico' + SQL + if x > 0 + raise "materials に #{ x } 件のチンカスがあります!" + end + + x = connection.select_value(<<~SQL) + SELECT + COUNT(0) + FROM + tag_implications ti + INNER JOIN + tags t + ON + t.id = ti.tag_id + OR t.id = ti.parent_tag_id + WHERE + t.category = 'nico' + SQL + if x > 0 + raise "tag_implications に #{ x } 件のチンカスがあります!" + end + + execute <<~SQL + DELETE + ts + FROM + tag_similarities ts + INNER JOIN + tags t + ON + t.category = 'nico' + AND (t.id = ts.tag_id + OR t.id = ts.target_tag_id) + SQL + + execute <<~SQL + DELETE + tset + FROM + theatre_skip_event_tags tset + INNER JOIN + tags t + ON + t.category = 'nico' + AND t.id = tset.tag_id + SQL + + remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema' + + say_with_time 'Migrate post_versions.tags_json' do + count = 0 + + MigrationPostVersion.find_each(batch_size: 500) do |version| + tags = version.tags_json.map do |tag| + if tag.fetch('category') == 'nico' + { 'external_tag_id' => tag.fetch('id') } + else + { 'tag_id' => tag.fetch('id'), + 'version_no' => tag.fetch('version_no'), + 'name' => tag.fetch('name'), + 'category' => tag.fetch('category'), + 'sections' => tag.fetch('sections') } + end + end + + version.update_columns(tags_json: tags) + count += 1 + end + + count + end + + add_tags_json_constraint! + + tag_name_ids = connection.select_values(<<~SQL) + SELECT + tag_name_id + FROM + tags + WHERE + category = 'nico' + SQL + + connection.transaction do + execute <<~SQL + DELETE + pt + FROM + post_tags pt + INNER JOIN + tags t + ON + t.category = 'nico' + AND t.id = pt.tag_id + SQL + + execute <<~SQL + DELETE + FROM + tags + WHERE + category = 'nico' + SQL + + unless tag_name_ids.empty? + execute <<~SQL + DELETE + FROM + tag_names + WHERE + id IN (#{ tag_name_ids.join(', ') }) + SQL + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration, '戻せません.' + end + + private + + def add_tags_json_constraint! + schema = { type: 'array', + items: { oneOf: [internal_tag_schema, external_tag_schema] } } + + quoted_schema = connection.quote(JSON.generate(schema)) + + add_check_constraint :post_versions, + "JSON_SCHEMA_VALID(#{ quoted_schema }, tags_json)", + name: 'chk_post_versions_tags_json_schema' + end + + def internal_tag_schema + { type: 'object', + properties: { tag_id: { type: 'integer', minimum: 1 }, + version_no: { type: 'integer', minimum: 1 }, + name: { type: 'string', minLength: 1 }, + category: { type: 'string', + enum: ['deerjikist', + 'meme', + 'character', + 'general', + 'material', + 'meta'] }, + sections: sections_schema }, + required: ['tag_id', 'version_no', 'sections'], + additionalProperties: false } + end + + def external_tag_schema + { type: 'object', + properties: { external_tag_id: { type: 'integer', minimum: 1 } }, + required: ['external_tag_id'], + additionalProperties: false } + end + + def sections_schema + { type: 'array', + items: { type: 'object', + properties: { begin_ms: { type: 'integer', minimum: 0 }, + end_ms: { type: ['integer', 'null'], minimum: 0 } }, + required: ['begin_ms', 'end_ms'], + additionalProperties: false } } + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 1cb6423..c4ce56a 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do +ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -48,6 +48,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.index ["tag_id"], name: "index_deerjikists_on_tag_id" end + create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "platform", limit: 16, null: false + t.string "name", null: false + t.integer "post_count", default: 0, null: false + t.datetime "created_at", null: false + t.index ["platform", "name"], name: "index_external_tags_on_platform_and_name", unique: true + end + create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "model", null: false t.integer "input_tokens", default: 0, null: false @@ -281,6 +289,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" end + create_table "post_external_tags", primary_key: ["post_id", "external_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "post_id", null: false + t.bigint "external_tag_id", null: false + t.datetime "created_at", null: false + t.index ["external_tag_id"], name: "index_post_external_tags_on_external_tag_id" + end + create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "parent_post_id", null: false @@ -352,7 +367,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid" t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive" - t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\",\"nico\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"id\",\"version_no\",\"name\",\"category\",\"sections\"],\"additionalProperties\":false}}',`tags_json`)", name: "chk_post_versions_tags_json_schema" + t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"oneOf\":[{\"type\":\"object\",\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"tag_id\",\"version_no\",\"sections\"],\"additionalProperties\":false},{\"type\":\"object\",\"properties\":{\"external_tag_id\":{\"type\":\"integer\",\"minimum\":1}},\"required\":[\"external_tag_id\"],\"additionalProperties\":false}]}}',`tags_json`)", name: "chk_post_versions_tags_json_schema" end create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -700,9 +715,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do add_foreign_key "materials", "tags" add_foreign_key "materials", "users", column: "created_by_user_id" add_foreign_key "materials", "users", column: "updated_by_user_id" + add_foreign_key "nico_tag_relations", "external_tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags" - add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" + add_foreign_key "post_external_tags", "external_tags" + add_foreign_key "post_external_tags", "posts" add_foreign_key "post_implications", "posts" add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_similarities", "posts" diff --git a/backend/lib/tasks/sync_nico.rake b/backend/lib/tasks/sync_nico.rake index f95fba1..db386a3 100644 --- a/backend/lib/tasks/sync_nico.rake +++ b/backend/lib/tasks/sync_nico.rake @@ -30,9 +30,28 @@ namespace :nico do end end - PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt| - pt.destroy! + PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each(&:destroy!) + end + + def sync_post_external_tags! post, desired_external_tag_ids, current_external_tag_ids: nil + current_external_tag_ids ||= + PostExternalTag.where(post_id: post.id).pluck(:external_tag_id).to_set + desired_external_tag_ids = desired_external_tag_ids.compact.to_set + + to_add = desired_external_tag_ids - current_external_tag_ids + to_remove = current_external_tag_ids - desired_external_tag_ids + + ExternalTag.where(id: to_add.to_a).find_each do |external_tag| + begin + PostExternalTag.create!(post:, external_tag:) + rescue ActiveRecord::RecordNotUnique + ; + end end + + PostExternalTag + .where(post_id: post.id, external_tag_id: to_remove.to_a) + .find_each(&:destroy!) end mysql_user = ENV['MYSQL_USER'] @@ -111,58 +130,64 @@ namespace :nico do sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id]) end - tags = post.tags # 既存のタグ Id. 集合 - kept_tag_ids = tags.pluck(:id).to_set - # うち内部タグ Id. 集合 - kept_non_nico_tag_ids = tags.not_nico.pluck(:id).to_set + kept_tag_ids = post.tags.pluck(:id).to_set + + # 既存の外部タグ Id. 集合 + kept_external_tag_ids = post.external_tags.nico.pluck(:id).to_set + + # 記載すべき外部タグ Id. のリスト + desired_external_tag_ids = [] - # 記載すべき外部タグ Id. および連携される内部タグ Id. のリスト - desired_nico_tag_based_ids = [] # 記載すべき内部タグ Id. のリスト - desired_non_nico_tag_ids = [] + desired_tag_ids = kept_tag_ids.to_a datum['tags'].each do |raw| - name = TagNameSanitisationRule.sanitise("nico:#{ raw }") - tag = Tag.find_or_create_by_tag_name!(name, category: :nico) + name = TagNameSanitisationRule.sanitise("nico:#{ raw }").delete_prefix('nico:') + tag = ExternalTag.find_or_create_by!(platform: :nico, name:) - event_type = tag.nico_tag_versions.exists? ? :update : :create - NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil) + unless tag.nico_tag_versions.exists? + NicoTagVersionRecorder.record!(external_tag: tag, + event_type: :create, + created_by_user: nil) + end - desired_nico_tag_based_ids << tag.id + desired_external_tag_ids << tag.id # 新たに記載される外部タグと連携される内部タグを記載 - unless tag.id.in?(kept_tag_ids) - linked_ids = tag.linked_tags.pluck(:id) - desired_non_nico_tag_ids.concat(linked_ids) - desired_nico_tag_based_ids.concat(linked_ids) + # 連携タグは記載すれども消除せず. + unless tag.id.in?(kept_external_tag_ids) + desired_tag_ids.concat(tag.linked_tags.pluck(:id)) end end deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user']) if deerjikist - desired_non_nico_tag_ids << deerjikist.tag_id - desired_nico_tag_based_ids << deerjikist.tag_id - elsif !(Tag.where(id: kept_non_nico_tag_ids).where(category: :deerjikist).exists?) - desired_non_nico_tag_ids << Tag.no_deerjikist.id - desired_nico_tag_based_ids << Tag.no_deerjikist.id + desired_tag_ids << deerjikist.tag_id + elsif !(Tag.where(id: kept_tag_ids).where(category: :deerjikist).exists?) + desired_tag_ids << Tag.no_deerjikist.id end - desired_nico_tag_based_ids.uniq! + desired_external_tag_ids.uniq! + desired_tag_ids.uniq! - desired_all_tag_ids = kept_non_nico_tag_ids.to_a + desired_nico_tag_based_ids - desired_non_nico_tag_ids.concat(kept_non_nico_tag_ids.to_a) - desired_non_nico_tag_ids.uniq! - if kept_non_nico_tag_ids != desired_non_nico_tag_ids.to_set - desired_all_tag_ids << Tag.bot.id + # 外部タグの記載に際しては “bot 操作” タグを記載しなぃ. + if kept_tag_ids != desired_tag_ids.to_set + desired_tag_ids << Tag.bot.id + desired_tag_ids.uniq! end - desired_all_tag_ids.uniq! - sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids) + tags_changed = + kept_tag_ids != desired_tag_ids.to_set || + kept_external_tag_ids != desired_external_tag_ids.to_set + + sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids) + sync_post_external_tags!(post, desired_external_tag_ids, + current_external_tag_ids: kept_external_tag_ids) if post_created PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) - elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set + elsif post_changed || tags_changed PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) end diff --git a/backend/spec/db/external_tags_spec.rb b/backend/spec/db/external_tags_spec.rb new file mode 100644 index 0000000..4159bdc --- /dev/null +++ b/backend/spec/db/external_tags_spec.rb @@ -0,0 +1,191 @@ +require 'rails_helper' +require_relative '../../db/migrate/20260921040000_create_external_tags' +require_relative '../../db/migrate/20260921050000_create_post_external_tags' +require_relative '../../db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations' +require_relative '../../db/migrate/20260921230000_migrate_external_tags' + +RSpec.describe 'external tag migrations' do + self.use_transactional_tests = false + + before do + record_class = Class.new(ActiveRecord::Base) do + self.abstract_class = true + end + stub_const('ExternalTagMigrationRecord', record_class) + config = ActiveRecord::Base.connection_db_config.configuration_hash + @database = "btrc_hub_test_external_tags_#{ Process.pid }_#{ SecureRandom.hex(4) }" + record_class.establish_connection(config.merge(database: nil)) + @connection = record_class.lease_connection + @connection.create_database(@database) + @database_created = true + @connection.execute("USE #{ @connection.quote_table_name(@database) }") + version_class = Class.new(record_class) do + self.table_name = 'post_versions' + end + stub_const('MigrateExternalTags::MigrationPostVersion', version_class) + create_legacy_tables + seed_legacy_records + end + + after do + @connection.drop_database(@database) if @database_created + ensure + ExternalTagMigrationRecord.remove_connection + end + + def migrate klass + migration = klass.new + allow(migration).to receive(:connection).and_return(@connection) + migration.suppress_messages { migration.migrate(:up) } + end + + def create_legacy_tables + @connection.create_table(:tag_names) { |t| t.string :name } + @connection.create_table(:tags) do |t| + t.references :tag_name, foreign_key: true + t.string :category + t.integer :post_count + t.datetime :created_at + end + @connection.create_table(:posts) + @connection.create_table(:post_tags) do |t| + t.references :post, foreign_key: true + t.references :tag, foreign_key: true + t.datetime :created_at + end + @connection.create_table(:nico_tag_versions) do |t| + t.bigint :tag_id + t.integer :version_no + t.string :name + t.datetime :created_at + end + @connection.create_table(:nico_tag_relations) do |t| + t.references :tag, foreign_key: true + t.references :nico_tag, foreign_key: { to_table: :tags } + end + [:post_tag_sections, :materials, :theatre_skip_event_tags].each do |table| + @connection.create_table(table) { |t| t.references :tag, foreign_key: true } + end + @connection.create_table(:tag_implications) do |t| + t.references :tag, foreign_key: true + t.references :parent_tag, foreign_key: { to_table: :tags } + end + @connection.create_table(:tag_similarities) do |t| + t.references :tag, foreign_key: true + t.references :target_tag, foreign_key: { to_table: :tags } + end + @connection.create_table(:post_versions) { |t| t.json :tags_json, null: false } + @connection.add_check_constraint(:post_versions, 'JSON_VALID(tags_json)', + name: 'chk_post_versions_tags_json_schema') + end + + def seed_legacy_records + @connection.execute(<<~SQL) + INSERT INTO tag_names (id, name) VALUES (1, 'internal'), (2, 'nico:raw tag[]'); + SQL + @connection.execute(<<~SQL) + INSERT INTO tags (id, tag_name_id, category, post_count, created_at) VALUES + (1, 1, 'general', 1, '2026-09-01'), (2, 2, 'nico', 1, '2026-09-02') + SQL + @connection.execute('INSERT INTO posts (id) VALUES (1)') + @connection.execute(<<~SQL) + INSERT INTO post_tags (post_id, tag_id, created_at) VALUES + (1, 1, '2026-09-03'), (1, 2, '2026-09-04') + SQL + @connection.execute(<<~SQL) + INSERT INTO nico_tag_versions (tag_id, version_no, name, created_at) VALUES + (2, 1, 'nico:raw tag[]', '2026-09-02'), + (3, 1, 'nico:deleted', '2026-09-01'), + (3, 2, 'nico:deleted_later', '2026-09-02') + SQL + @connection.execute('INSERT INTO nico_tag_relations (tag_id, nico_tag_id) VALUES (1, 2)') + @connection.execute(<<~SQL) + INSERT INTO tag_similarities (tag_id, target_tag_id) VALUES (1, 1), (1, 2), (2, 1) + SQL + @connection.execute('INSERT INTO theatre_skip_event_tags (tag_id) VALUES (1), (2)') + @legacy_internal = { 'id' => 1, 'version_no' => 2, + 'name' => 'internal', 'category' => 'general', + 'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }] } + snapshots = [@legacy_internal, + { 'id' => 2, 'category' => 'nico' }, + { 'id' => 3, 'category' => 'nico' }] + @connection.execute(<<~SQL) + INSERT INTO post_versions (tags_json) VALUES + (#{ @connection.quote(snapshots.to_json) }), ('[]') + SQL + end + + def prepare_external_tables + migrate(CreateExternalTags) + migrate(CreatePostExternalTags) + migrate(ChangeForeignKeyOnNicoTagRelations) + end + + it 'preserves ids, raw names, counts, timestamps, post links and historical-only tags' do + prepare_external_tables + + rows = @connection.select_rows(<<~SQL) + SELECT id, platform, name, post_count, DATE_FORMAT(created_at, '%Y-%m-%d') + FROM external_tags ORDER BY id + SQL + expect(rows).to eq([ + [2, 'nico', 'raw tag[]', 1, '2026-09-02'], + [3, 'nico', 'deleted', 0, '2026-09-01']]) + expect(@connection.select_rows(<<~SQL)).to eq([[1, 2, '2026-09-04']]) + SELECT post_id, external_tag_id, DATE_FORMAT(created_at, '%Y-%m-%d') + FROM post_external_tags + SQL + expect(@connection.foreign_key_exists?(:nico_tag_relations, :external_tags, + column: :nico_tag_id)).to be(true) + expect(@connection.foreign_key_exists?(:nico_tag_relations, :tags, + column: :tag_id)).to be(true) + end + + it 'converts snapshots and removes only obsolete internal rows and derived links' do + prepare_external_tables + migrate(MigrateExternalTags) + + expect(@connection.select_values('SELECT id FROM tags')).to eq([1]) + expect(@connection.select_values('SELECT id FROM tag_names')).to eq([1]) + expect(@connection.select_values('SELECT tag_id FROM post_tags')).to eq([1]) + expect(@connection.select_rows('SELECT tag_id, nico_tag_id FROM nico_tag_relations')) + .to eq([[1, 2]]) + expect(@connection.select_rows('SELECT tag_id, target_tag_id FROM tag_similarities')) + .to eq([[1, 1]]) + expect(@connection.select_values('SELECT tag_id FROM theatre_skip_event_tags')).to eq([1]) + expect(@connection.select_value('SELECT COUNT(*) FROM nico_tag_versions')).to eq(3) + versions = MigrateExternalTags::MigrationPostVersion.order(:id) + expect(versions.first.tags_json).to eq([ + @legacy_internal.except('id').merge('tag_id' => 1), + { 'external_tag_id' => 2 }, { 'external_tag_id' => 3 }]) + expect(versions.last.tags_json).to eq([]) + + invalid_snapshots = [@legacy_internal, + { 'external_tag_id' => 0 }, + { 'external_tag_id' => 2, 'tag_id' => 1 }, + { 'external_tag_id' => 2, 'name' => 'extra' }, + { 'tag_id' => 1, 'version_no' => 1 }] + invalid_snapshots.each do |snapshot| + expect { + MigrateExternalTags::MigrationPostVersion.create!(tags_json: [snapshot]) + }.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i) + end + expect { MigrateExternalTags.new.down } + .to raise_error(ActiveRecord::IrreversibleMigration) + end + + { post_tag_sections: '(tag_id) VALUES (2)', + materials: '(tag_id) VALUES (2)', + tag_implications: '(tag_id, parent_tag_id) VALUES (1, 2)' }.each do |table, values| + it "aborts before deleting data when #{ table } references a legacy nico tag" do + prepare_external_tables + @connection.execute("INSERT INTO #{ table } #{ values }") + + expect { migrate(MigrateExternalTags) }.to raise_error(RuntimeError, /#{ table }/) + + expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 2]) + expect(MigrateExternalTags::MigrationPostVersion.first.tags_json.first) + .to eq(@legacy_internal) + end + end +end diff --git a/backend/spec/factories/external_tags.rb b/backend/spec/factories/external_tags.rb new file mode 100644 index 0000000..09a9c56 --- /dev/null +++ b/backend/spec/factories/external_tags.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :external_tag do + platform { :nico } + sequence(:name) { |n| "external_tag_#{ n }" } + end +end diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index 9da4b9d..55db316 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -11,12 +11,5 @@ FactoryBot.define do after(:build) do |tag, evaluator| tag.name = evaluator.name if evaluator.name.present? end - - trait :nico do - category { :nico } - transient do - name { "nico:#{ SecureRandom.hex(4) }" } - end - end end end diff --git a/backend/spec/models/external_tag_spec.rb b/backend/spec/models/external_tag_spec.rb new file mode 100644 index 0000000..3d0bd86 --- /dev/null +++ b/backend/spec/models/external_tag_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe ExternalTag, type: :model do + it 'preserves names without internal tag sanitisation or TagName records' do + external = nil + + expect { + external = described_class.create!(platform: :nico, name: 'raw tag[]') + }.not_to change(TagName, :count) + + expect(external.reload.name).to eq('raw tag[]') + end + + it 'deletes post associations without deleting posts or version history' do + external = create(:external_tag) + post = create(:post) + PostExternalTag.create!(post:, external_tag: external) + version = NicoTagVersionRecorder.record!( + external_tag: external, event_type: :create, created_by_user: nil) + + external.destroy! + + expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty + expect(post.reload.external_tags).to be_empty + expect(version.reload.tag_id).to eq(external.id) + end + + it 'deletes external links without deleting linked internal tags' do + external = create(:external_tag) + tag = create(:tag) + NicoTagRelation.create!(nico_tag: external, tag:) + + external.destroy! + + expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty + expect(Tag.exists?(tag.id)).to be(true) + end +end diff --git a/backend/spec/models/nico_tag_relation_spec.rb b/backend/spec/models/nico_tag_relation_spec.rb new file mode 100644 index 0000000..89973ed --- /dev/null +++ b/backend/spec/models/nico_tag_relation_spec.rb @@ -0,0 +1,27 @@ +require 'rails_helper' + +RSpec.describe NicoTagRelation, type: :model do + it 'does not constrain the ExternalTag association to the nico platform' do + external_tag = create(:external_tag) + tag = create(:tag) + allow(external_tag).to receive(:platform).and_return('registered_external') + allow(external_tag).to receive(:nico?).and_return(false) + + expect { + described_class.create!(nico_tag: external_tag, tag:) + }.to change(described_class, :count).by(1) + expect(tag.linked_nico_tags).to contain_exactly(external_tag) + end + + it 'rejects an internal Tag through the external association type' do + expect { + described_class.new(nico_tag: create(:tag), tag: create(:tag)) + }.to raise_error(ActiveRecord::AssociationTypeMismatch) + end + + it 'rejects an ExternalTag through the internal association type' do + expect { + described_class.new(nico_tag: create(:external_tag), tag: create(:external_tag)) + }.to raise_error(ActiveRecord::AssociationTypeMismatch) + end +end diff --git a/backend/spec/models/post_external_tag_spec.rb b/backend/spec/models/post_external_tag_spec.rb new file mode 100644 index 0000000..02bd2a7 --- /dev/null +++ b/backend/spec/models/post_external_tag_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe PostExternalTag, type: :model do + it 'exposes both sides and enforces a unique post and external tag pair' do + post = create(:post) + external_tag = create(:external_tag) + described_class.create!(post:, external_tag:) + + expect(post.external_tags).to contain_exactly(external_tag) + expect(external_tag.posts).to contain_exactly(post) + expect { described_class.create!(post:, external_tag:) } + .to raise_error(ActiveRecord::RecordNotUnique) + end + + it 'removes associations when the post is deleted while retaining the external tag' do + post = create(:post) + external_tag = create(:external_tag) + described_class.create!(post:, external_tag:) + + post.destroy! + + expect(described_class.where(post_id: post.id)).to be_empty + expect(external_tag.reload.posts).to be_empty + end +end diff --git a/backend/spec/models/post_spec.rb b/backend/spec/models/post_spec.rb index c097fdf..ea5646e 100644 --- a/backend/spec/models/post_spec.rb +++ b/backend/spec/models/post_spec.rb @@ -6,6 +6,25 @@ RSpec.describe Post, type: :model do PostUrlSanitisationRule.unscoped.delete_all end + describe '#snapshot_tags_json' do + it 'keeps internal snapshots and external identifiers distinct, even with the same id' do + post = create(:post) + tag = create(:tag) + external = create(:external_tag, id: tag.id) + create(:post_tag, post:, tag:) + create(:post_tag_section, post:, tag:, begin_ms: 2000, end_ms: nil) + PostExternalTag.create!(post:, external_tag: external) + + expect(post.snapshot_tags_json).to eq([ + { 'tag_id' => tag.id, + 'version_no' => tag.version_no, + 'name' => tag.name, + 'category' => tag.category, + 'sections' => [{ 'begin_ms' => 2000, 'end_ms' => nil }] }, + { 'external_tag_id' => external.id }]) + end + end + describe 'URL normalisation' do it 'normalises the HTTP URL before applying sanitisation rules' do PostUrlSanitisationRule.create!( diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index e532e25..d63be45 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -1,6 +1,29 @@ require 'rails_helper' RSpec.describe Tag, type: :model do + describe 'external tag separation' do + ['nico:reserved', 'NiCo:reserved'].each do |name| + it "rejects the reserved prefix #{ name } for internal tags" do + tag = build(:tag, name:) + + expect(tag).to be_invalid + expect(tag.errors[:name]).to be_present + expect { + described_class.normalise_tags!([name]) + }.to raise_error(Tag::NicoTagNormalisationError) + end + end + + it 'finds external links by the internal tag id even when ids differ' do + tag = create(:tag) + external = create(:external_tag, id: tag.id + 10_000) + # The migration retains existing links without running model validation. + NicoTagRelation.insert_all!([{ tag_id: tag.id, nico_tag_id: external.id }]) + + expect(tag.linked_nico_tags).to contain_exactly(external) + end + end + describe '.normalise_tags!' do it 'rejects deprecated tags when deny_deprecated is enabled' do tag_name = TagName.create!(name: 'normalise deprecated tag') @@ -159,20 +182,6 @@ RSpec.describe Tag, type: :model do end end - describe 'deprecated validation' do - it 'rejects deprecated nico tags' do - tag = build( - :tag, - name: 'nico:deprecated_validation', - category: :nico, - deprecated_at: Time.current - ) - - expect(tag).not_to be_valid - expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.') - end - end - describe '.find_or_create_by_tag_name!' do it 'creates a tag and name with the requested category after stripping whitespace' do tag = nil @@ -290,7 +299,7 @@ RSpec.describe Tag, type: :model do it 'deletes source relationships while preserving unrelated relationships' do parent = create(:tag) child = create(:tag) - nico_tag = create(:tag, :nico) + nico_tag = create(:external_tag) TagImplication.create!(tag: source_tag, parent_tag: parent) TagImplication.create!(tag: child, parent_tag: source_tag) kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent) @@ -412,48 +421,6 @@ RSpec.describe Tag, type: :model do end end - context 'when merging a nico source tag' do - let!(:target_tag) do - create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:foo')) - end - let!(:source_tag) do - create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:bar')) - end - let!(:source_tag_name_id) { source_tag.tag_name_id } - - it 'deletes the source tag and name instead of keeping an alias' do - described_class.merge_tags!(target_tag, [source_tag]) - - expect(Tag.unscoped.exists?(source_tag.id)).to be(false) - expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) - expect(target_tag.reload.post_count).to eq(0) - end - - it 'keeps nico history while deleting source links and allows recreating the name' do - linked_tag = create(:tag) - NicoTagRelation.create!(nico_tag: source_tag, tag: linked_tag) - kept_relation = NicoTagRelation.create!(nico_tag: target_tag, tag: linked_tag) - user = create_member_user! - source_name = source_tag.name - - described_class.merge_tags!(target_tag, [source_tag], created_by_user: user) - - expect(NicoTagRelation.all).to contain_exactly(kept_relation) - versions = NicoTagVersion.where(tag_id: source_tag.id).order(:version_no) - expect(versions.pluck(:version_no, :event_type)) - .to eq([[1, 'create'], [2, 'discard']]) - expect(versions.last).to have_attributes( - name: source_name, linked_tags: linked_tag.name, created_by_user: user) - - recreated = described_class.find_or_create_by_tag_name!(source_name, category: :nico) - - expect(recreated.id).not_to eq(source_tag.id) - expect(recreated.tag_name_id).not_to eq(source_tag_name_id) - expect(recreated.nico_tag_versions).to be_empty - expect(versions.reload.size).to eq(2) - end - end - def snapshot_tags(post) post.snapshot_tag_names.join(' ') end @@ -499,8 +466,8 @@ RSpec.describe Tag, type: :model do expect(latest.event_type).to eq('update') expect(latest.created_by_user).to be_nil expect(latest.tags).to eq(snapshot_tags(post_record.reload)) - expect(latest.tags_json.map { |item| item.fetch('id') }).to eq([target_tag.id]) - expect(affected_versions.first.tags_json.map { |item| item.fetch('id') }) + expect(latest.tags_json.map { |item| item.fetch('tag_id') }).to eq([target_tag.id]) + expect(affected_versions.first.tags_json.map { |item| item.fetch('tag_id') }) .to eq([source_tag.id]) expect(unaffected_post.reload.post_versions.count).to eq(1) diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb index d3acb34..e76f4e7 100644 --- a/backend/spec/models/version_record_spec.rb +++ b/backend/spec/models/version_record_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe VersionRecord, type: :model do let!(:tag) { create(:tag, name: 'version_record_tag') } - let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') } + let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') } it 'makes TagVersion read only after create' do version = TagVersion.create!( @@ -42,10 +42,10 @@ RSpec.describe VersionRecord, type: :model do it 'makes NicoTagVersion read only after create' do version = NicoTagVersion.create!( - tag: nico_tag, + external_tag: nico_tag, version_no: 1, event_type: 'create', - name: nico_tag.name, + name: "nico:#{ nico_tag.name }", linked_tags: '', created_at: Time.current, created_by_user: nil @@ -58,10 +58,10 @@ RSpec.describe VersionRecord, type: :model do it 'prevents NicoTagVersion destroy' do version = NicoTagVersion.create!( - tag: nico_tag, + external_tag: nico_tag, version_no: 1, event_type: 'create', - name: nico_tag.name, + name: "nico:#{ nico_tag.name }", linked_tags: '', created_at: Time.current, created_by_user: nil diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index b9ac6c6..243cbd3 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -3,10 +3,34 @@ require 'rails_helper' RSpec.describe 'NicoTags', type: :request do describe 'GET /tags/nico' do - it 'returns paginated tags and total count' do - create_list(:tag, 3, :nico) + it 'returns the legacy Tag-compatible external fields' do + external = create(:external_tag, name: 'legacy_external', post_count: 3) - get '/tags/nico', params: { page: 2, limit: 2 } + get '/tags/nico', params: { name: 'legacy_external' } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(1) + expect(json.fetch('tags')).to contain_exactly( + a_hash_including( + 'id' => external.id, + 'name' => 'nico:legacy_external', + 'category' => 'nico', + 'post_count' => 3, + 'created_at' => external.created_at.as_json, + 'updated_at' => external.created_at.as_json, + 'deprecated_at' => nil, + 'aliases' => [], + 'parents' => [], + 'has_wiki' => false, + 'material_id' => nil, + 'has_deerjikists' => false, + 'linked_tags' => [])) + end + + it 'returns paginated tags and total count' do + 3.times { |i| create(:external_tag, name: "pagination_#{ i }") } + + get '/tags/nico', params: { page: 2, limit: 2, name: 'pagination_' } expect(response).to have_http_status(:ok) expect(json['tags'].size).to eq(1) @@ -14,12 +38,12 @@ RSpec.describe 'NicoTags', type: :request do end it 'filters by nico tag name, linked tag name, and link status' do - linked = create(:tag, :nico) - linked.tag_name.update!(name: 'nico:search_linked') - unlinked = create(:tag, :nico) - unlinked.tag_name.update!(name: 'nico:search_unlinked') - other = create(:tag, :nico) - other.tag_name.update!(name: 'nico:other') + linked = create(:external_tag) + linked.update!(name: 'search_linked') + unlinked = create(:external_tag) + unlinked.update!(name: 'search_unlinked') + other = create(:external_tag) + other.update!(name: 'other') destination = create(:tag, :general) destination.tag_name.update!(name: 'destination_search') NicoTagRelation.create!(nico_tag: linked, tag: destination) @@ -41,37 +65,77 @@ RSpec.describe 'NicoTags', type: :request do expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id]) end + it 'filters by the qualified legacy name as well as the raw name' do + external = create(:external_tag, name: 'qualified_filter') + create(:external_tag, name: 'unrelated_filter') + + ['qualified_filter', 'nico:qualified_filter'].each do |name| + get '/tags/nico', params: { name: } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(1) + expect(json.fetch('tags')).to contain_exactly( + a_hash_including('id' => external.id, 'name' => 'nico:qualified_filter')) + end + end + it 'sorts by name and timestamps' do - older = create(:tag, :nico) - older.tag_name.update!(name: 'nico:a') + older = create(:external_tag) + older.update!(name: 'ordered_a') older.update_columns(created_at: 2.days.ago) - newer = create(:tag, :nico) - newer.tag_name.update!(name: 'nico:b') + newer = create(:external_tag) + newer.update!(name: 'ordered_b') newer.update_columns(created_at: 1.day.ago) older_post_tag = - PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older) + PostExternalTag.create!(post: create(:post), external_tag: older) older_post_tag.update_columns(created_at: 1.hour.ago) newer_post_tag = - PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer) + PostExternalTag.create!(post: create(:post), external_tag: newer) newer_post_tag.update_columns(created_at: 2.hours.ago) - get '/tags/nico', params: { order: 'name:desc' } + get '/tags/nico', params: { order: 'name:desc', name: 'ordered_' } expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id]) - get '/tags/nico', params: { order: 'created_at:asc' } + get '/tags/nico', params: { order: 'created_at:asc', name: 'ordered_' } expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id]) - get '/tags/nico', params: { order: 'updated_at:desc' } + get '/tags/nico', params: { order: 'updated_at:desc', name: 'ordered_' } expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id]) expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at'))) .to be_within(1.second).of(older_post_tag.created_at) end end + describe 'GET /tags/nico/:id' do + it 'returns the external tag even when an internal tag has the same id' do + internal = create(:tag) + external = create( + :external_tag, + id: internal.id, + name: 'external_detail') + + get "/tags/nico/#{ external.id }" + + expect(response).to have_http_status(:ok) + expect(json).to include( + 'id' => external.id, + 'name' => 'nico:external_detail', + 'category' => 'nico') + end + + it 'returns 404 when the external tag does not exist' do + internal = create(:tag) + + get "/tags/nico/#{ internal.id }" + + expect(response).to have_http_status(:not_found) + end + end + describe 'PATCH /tags/nico/:id' do let(:member) { create(:user, :member) } let(:admin) { create(:user, :admin) } - let(:nico_tag) { create(:tag, :nico) } + let(:nico_tag) { create(:external_tag) } it '401 when not logged in' do sign_out @@ -85,18 +149,18 @@ RSpec.describe 'NicoTags', type: :request do expect(response).to have_http_status(:forbidden) end - it '400 when target is not nico category' do + it '404 when only an internal tag exists for the target id' do sign_in_as(member) non_nico = create(:tag, :general) + expect(ExternalTag.exists?(non_nico.id)).to be(false) patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } - expect(response).to have_http_status(:bad_request) + expect(response).to have_http_status(:not_found) end it '200 and updates linked tags while recording tag versions' do sign_in_as(admin) - nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source') - nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) + nico_tag = create(:external_tag, name: 'nico_tags_spec_source') linked_a_name = TagName.create!(name: 'nico_linked_a') linked_a = Tag.create!(tag_name: linked_a_name, category: :general) @@ -104,7 +168,8 @@ RSpec.describe 'NicoTags', type: :request do linked_b_name = TagName.create!(name: 'nico_linked_b') linked_b = Tag.create!(tag_name: linked_b_name, category: :general) - TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin) + NicoTagVersionRecorder.record!(external_tag: nico_tag, + event_type: :create, created_by_user: admin) expect { patch "/tags/nico/#{nico_tag.id}", params: { @@ -126,26 +191,26 @@ RSpec.describe 'NicoTags', type: :request do expect(versions.map(&:event_type)).to eq(['create', 'update']) expect(versions.last.linked_tags.split).to match_array([ 'nico_linked_a', - 'nico_linked_b' - ]) + 'nico_linked_b']) expect(versions.last.created_by_user_id).to eq(admin.id) end - it 'returns 422 when linked tag normalises to nico tag' do + it 'clears existing links and records the empty mapping for a member' do sign_in_as(member) - - other_nico = create(:tag, :nico, name: 'nico:linked_ng') - TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name) - - TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member) + linked = create(:tag) + NicoTagRelation.insert_all!([{ nico_tag_id: nico_tag.id, tag_id: linked.id }]) + NicoTagVersionRecorder.record!(external_tag: nico_tag, + event_type: :create, created_by_user: member) expect { - patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } - }.not_to change(NicoTagVersion, :count) + patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' } + }.to change(NicoTagVersion, :count).by(1) - expect(response).to have_http_status(:unprocessable_entity) - expect(json.fetch('errors')).to include( - 'tags' => ['ニコニコ・タグ同士は連携できません.']) + expect(response).to have_http_status(:ok) + expect(json).to eq([]) + expect(nico_tag.reload.linked_tags).to be_empty + expect(nico_tag.nico_tag_versions.order(:version_no).last) + .to have_attributes(linked_tags: '', created_by_user: member) end it 'returns the tags field error when a nico tag is specified directly' do diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index fb08d21..8660634 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -17,7 +17,7 @@ RSpec.describe 'Posts API', type: :request do end def create_nico_tag!(name) - Tag.find_or_create_by_tag_name!(name, category: :nico) + ExternalTag.find_or_create_by!(platform: :nico, name: name.delete_prefix('nico:')) end def dummy_upload @@ -93,6 +93,26 @@ RSpec.describe 'Posts API', type: :request do count end + def expect_external_tag_json tag_json, external_tag + external_tag.reload + + expect(tag_json).to include( + 'id' => external_tag.id, + 'name' => "#{ external_tag.platform }:#{ external_tag.name }", + 'category' => 'nico', + 'created_at' => external_tag.created_at.as_json, + 'updated_at' => external_tag.created_at.as_json, + 'deprecated_at' => nil, + 'aliases' => [], + 'parents' => [], + 'post_count' => external_tag.post_count, + 'has_wiki' => false, + 'material_id' => nil, + 'has_deerjikists' => false, + 'children' => [], + 'sections' => []) + end + let!(:tag_name) { TagName.create!(name: 'spec_tag') } let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } @@ -233,6 +253,93 @@ RSpec.describe 'Posts API', type: :request do end end + context 'with legacy external tag name searches' do + let!(:external_tag) { create(:external_tag, id: tag.id, name: 'search_external') } + let!(:both_post) do + create(:post).tap do |post| + PostTag.create!(post:, tag:) + PostExternalTag.create!(post:, external_tag:) + end + end + + before do + PostExternalTag.create!(post: miss_post, external_tag:) + end + + it 'keeps internal name searches independent of colliding external ids' do + get '/posts', params: { tags: tag.name } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(3) + expect(json.fetch('posts').map { _1.fetch('id') }) + .to contain_exactly(post_record.id, hit_post.id, both_post.id) + end + + it 'finds posts through PostExternalTag by the qualified legacy name' do + get '/posts', params: { tags: 'nico:search_external' } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + expect(json.fetch('posts').map { _1.fetch('id') }) + .to contain_exactly(miss_post.id, both_post.id) + end + + [nil, 'all'].each do |match| + it "intersects internal and external matches with match=#{ match || 'omitted' }" do + params = { tags: "#{ tag.name } nico:search_external" } + params[:match] = match if match + + get '/posts', params: params + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(1) + expect(json.fetch('posts').map { _1.fetch('id') }).to eq([both_post.id]) + end + end + + it 'unions internal alias and external matches without duplicate posts' do + get '/posts', params: { tags: 'manko nico:search_external', match: 'any' } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(4) + expect(json.fetch('posts').map { _1.fetch('id') }) + .to contain_exactly(post_record.id, hit_post.id, miss_post.id, both_post.id) + end + + it 'excludes external matches from an internal tag search' do + get '/posts', params: { tags: "#{ tag.name } not:nico:search_external" } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + expect(json.fetch('posts').map { _1.fetch('id') }) + .to contain_exactly(post_record.id, hit_post.id) + end + + it 'unions an external match with a negated internal match' do + get '/posts', params: { tags: "nico:search_external not:#{ tag.name }", match: 'any' } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + expect(json.fetch('posts').map { _1.fetch('id') }) + .to contain_exactly(miss_post.id, both_post.id) + end + + it 'keeps a missing qualified external name empty' do + get '/posts', params: { tags: 'nico:missing_search_external' } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(0) + expect(json.fetch('posts')).to be_empty + end + + it 'applies the same mixed name search to the existing random endpoint' do + get '/posts/random', params: { tags: "#{ tag.name } nico:search_external", match: 'all' } + + expect(response).to have_http_status(:ok) + expect(json.fetch('id')).to eq(both_post.id) + end + end + context 'when tags contain not:' do let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') } let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) } @@ -612,6 +719,30 @@ RSpec.describe 'Posts API', type: :request do expect(json.fetch('count')).to eq(2) end end + + it 'returns internal and external tags with colliding ids in the legacy tags array' do + external_tag = create(:external_tag, id: tag.id, name: 'post_index_external') + PostExternalTag.create!(post: hit_post, external_tag:) + + get '/posts' + + expect(response).to have_http_status(:ok) + + post_json = + json + .fetch('posts') + .find { _1.fetch('id') == hit_post.id } + + external_json = + post_json + .fetch('tags') + .find { _1['name'] == 'nico:post_index_external' } + + expect(post_json.fetch('tags')).to include( + a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general')) + expect(external_json).not_to be_nil + expect_external_tag_json(external_json, external_tag) + end end describe 'GET /posts/:id' do @@ -769,6 +900,25 @@ RSpec.describe 'Posts API', type: :request do expect(response).to have_http_status(:ok) expect(query_count).to be <= 45 end + + it 'returns external tags as root nodes in the legacy tag tree' do + external_tag = create(:external_tag, id: tag.id, name: 'post_detail_external') + PostExternalTag.create!(post: post_record, external_tag:) + + request + + expect(response).to have_http_status(:ok) + + expect(json.fetch('tags')).to include( + a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general')) + external_json = + json + .fetch('tags') + .find { _1['name'] == 'nico:post_detail_external' } + + expect(external_json).not_to be_nil + expect_external_tag_json(external_json, external_tag) + end end context 'when post does not exist' do @@ -1250,9 +1400,9 @@ RSpec.describe 'Posts API', type: :request do ) end - context 'when nico tag already exists in tags' do + context 'when the external nico tag already exists' do before do - Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico) + create(:external_tag, name: 'nico_tag') end it 'returns 422 with tag field errors' do @@ -1530,9 +1680,9 @@ RSpec.describe 'Posts API', type: :request do versions = post_record.post_versions.order(:version_no) expect(versions.first.tags_json).to include( - a_hash_including('id' => tag.id, + a_hash_including('tag_id' => tag.id, 'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }])) - expect(versions.last.tags_json.map { |item| item.fetch('id') }) + expect(versions.last.tags_json.map { |item| item.fetch('tag_id') }) .not_to include(tag.id) end @@ -1551,7 +1701,7 @@ RSpec.describe 'Posts API', type: :request do expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member) expect(tag.reload.post_count).to eq(1) snapshots = post_record.post_versions.order(:version_no).map do |version| - version.tags_json.map { |item| item.fetch('id') } + version.tags_json.map { |item| item.fetch('tag_id') } end expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true]) end @@ -1576,9 +1726,9 @@ RSpec.describe 'Posts API', type: :request do ) end - context 'when nico tag already exists in tags' do + context 'when the external nico tag already exists' do before do - Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico) + create(:external_tag, name: 'nico_tag') end it 'returns 422 with tag field errors' do @@ -1895,7 +2045,7 @@ RSpec.describe 'Posts API', type: :request do base_version = create_post_version_for!(post_record.reload) nico_tag = create_nico_tag!('nico:optimistic_lock_nico') - PostTag.create!(post: post_record, tag: nico_tag, created_user: member) + PostExternalTag.create!(post: post_record, external_tag: nico_tag) PostVersionRecorder.record!( post: post_record.reload, @@ -1915,14 +2065,14 @@ RSpec.describe 'Posts API', type: :request do expect(names).to include('spec_tag') expect(names).to include(Tag.no_deerjikist.name) - expect(names).to include(nico_tag.name) + expect(post_record.external_tags).to contain_exactly(nico_tag) end it 'keeps nico tags even when they are not included in PUT tags' do sign_in_as(member) nico_tag = create_nico_tag!('nico:readonly_update_nico') - PostTag.create!(post: post_record, tag: nico_tag, created_user: member) + PostExternalTag.create!(post: post_record, external_tag: nico_tag) base_version = create_post_version_for!(post_record.reload) @@ -1937,7 +2087,15 @@ RSpec.describe 'Posts API', type: :request do expect(names).to include('spec_tag') expect(names).to include(Tag.no_deerjikist.name) - expect(names).to include(nico_tag.name) + expect(post_record.external_tags).to contain_exactly(nico_tag) + + external_json = + json + .fetch('tags') + .find { _1['name'] == "nico:#{ nico_tag.name }" } + + expect(external_json).not_to be_nil + expect_external_tag_json(external_json, nico_tag) end it 'allows non-nico tags linked from nico tags to be removed by normal post update' do @@ -1947,7 +2105,7 @@ RSpec.describe 'Posts API', type: :request do linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general) NicoTagRelation.create!(nico_tag:, tag: linked_tag) - PostTag.create!(post: post_record, tag: nico_tag, created_user: member) + PostExternalTag.create!(post: post_record, external_tag: nico_tag) PostTag.create!(post: post_record, tag: linked_tag, created_user: member) base_version = create_post_version_for!(post_record.reload) @@ -1961,7 +2119,7 @@ RSpec.describe 'Posts API', type: :request do names = post_record.reload.tags.map(&:name) - expect(names).to include(nico_tag.name) + expect(post_record.external_tags).to contain_exactly(nico_tag) expect(names).to include('spec_tag') expect(names).to include(Tag.no_deerjikist.name) expect(names).not_to include(linked_tag.name) @@ -2180,6 +2338,88 @@ RSpec.describe 'Posts API', type: :request do expect(first.fetch('created_at')).to eq(t_v1.iso8601) end + context 'with external tag history' do + let(:external_id) { tag.id } + let(:external) { create(:external_tag, id: external_id) } + let(:external_post) { create(:post) } + + before do + PostExternalTag.create!(post: external_post, external_tag: external) + PostVersionRecorder.record!( + post: external_post, event_type: :create, created_by_user: member) + + unrelated_post = create(:post) + PostExternalTag.create!(post: unrelated_post, external_tag: create(:external_tag)) + PostVersionRecorder.record!( + post: unrelated_post, event_type: :create, created_by_user: member) + end + + it 'prefers Tag over ExternalTag for the legacy tag parameter' do + get '/posts/versions', params: { tag: tag.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(3) + expect(json.fetch('versions').map { [_1.fetch('post_id'), _1.fetch('version_no')] }) + .to contain_exactly( + [post_record.id, 1], [post_record.id, 2], [other_post_version.post_id, 1]) + end + + it 'explicitly filters ExternalTag even when its id collides with Tag' do + get '/posts/versions', params: { external_tag: external.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(1) + expect(json.fetch('versions')).to contain_exactly( + a_hash_including('post_id' => external_post.id, 'version_no' => 1)) + end + + # Temporary compatibility shim until the frontend sends external_tag explicitly. + context 'with legacy tag fallback and no internal Tag with the external id' do + let(:external_id) { Tag.maximum(:id).to_i + 10_000 } + + it 'falls back to ExternalTag for the legacy tag parameter' do + expect(Tag.exists?(external.id)).to be(false) + + get '/posts/versions', params: { tag: external.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(1) + expect(json.fetch('versions')).to contain_exactly( + a_hash_including('post_id' => external_post.id, 'version_no' => 1)) + end + + [:tag, :external_tag].each do |parameter| + it "includes external removal history through the legacy API's #{ parameter }" do + external_post.post_external_tags.destroy_all + PostVersionRecorder.record!( + post: external_post.reload, event_type: :update, created_by_user: member) + + get '/posts/versions', params: { parameter => external.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + expect(json.fetch('versions')).to contain_exactly( + a_hash_including('post_id' => external_post.id, 'version_no' => 1), + a_hash_including('post_id' => external_post.id, 'version_no' => 2)) + end + end + end + end + + it 'can render history containing external identifiers' do + PostExternalTag.create!(post: post_record, external_tag: create(:external_tag)) + post_record.update_columns(version_no: 2) + PostVersionRecorder.record!(post: post_record, + event_type: :update, created_by_user: member) + + get '/posts/versions', params: { post: post_record.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(3) + expect(json.fetch('versions').first.fetch('tags')).to include( + 'name' => tag2.name, 'type' => 'context') + end + it 'filters versions by tag when the current snapshot includes the tag' do get '/posts/versions', params: { post: post_record.id, tag: tag2.id } diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb index 69bb10a..dd22144 100644 --- a/backend/spec/requests/tag_children_spec.rb +++ b/backend/spec/requests/tag_children_spec.rb @@ -80,38 +80,6 @@ RSpec.describe "TagChildren", type: :request do expect(response).to have_http_status(:not_found) end end - - context 'when parent is nico' do - before { stub_current_user(admin) } - - let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') } - let(:parent_id) { parent.id } - let(:child_id) { child.id } - - it 'returns 400 and does not create relation' do - expect { - do_request - }.not_to change(TagImplication, :count) - - expect(response).to have_http_status(:bad_request) - end - end - - context 'when child is nico' do - before { stub_current_user(admin) } - - let!(:child) { create(:tag, :nico, name: 'nico:child_ng') } - let(:parent_id) { parent.id } - let(:child_id) { child.id } - - it 'returns 400 and does not create relation' do - expect { - do_request - }.not_to change(TagImplication, :count) - - expect(response).to have_http_status(:bad_request) - end - end end describe "DELETE /tag_children" do @@ -186,31 +154,5 @@ RSpec.describe "TagChildren", type: :request do expect(response).to have_http_status(:not_found) end end - - context 'when parent is nico' do - before { stub_current_user(admin) } - - let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') } - let(:parent_id) { parent.id } - let(:child_id) { child.id } - - it 'returns 400' do - do_request - expect(response).to have_http_status(:bad_request) - end - end - - context 'when child is nico' do - before { stub_current_user(admin) } - - let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') } - let(:parent_id) { parent.id } - let(:child_id) { child.id } - - it 'returns 400' do - do_request - expect(response).to have_http_status(:bad_request) - end - end end end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 0055364..3c1088c 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -30,6 +30,30 @@ RSpec.describe 'Tags API', type: :request do end describe 'GET /tags' do + it 'includes legacy external JSON alongside an internal tag with the same id' do + external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3) + + get '/tags', params: { name: 'spec_' } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(2) + expect(response_tags).to contain_exactly( + a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'), + a_hash_including( + 'id' => external.id, + 'name' => 'nico:spec_external', + 'category' => 'nico', + 'post_count' => 3, + 'created_at' => external.created_at.as_json, + 'updated_at' => external.created_at.as_json, + 'deprecated_at' => nil, + 'aliases' => [], + 'parents' => [], + 'has_wiki' => false, + 'material_id' => nil, + 'has_deerjikists' => false)) + end + it 'returns tags with count and metadata' do get '/tags' @@ -164,20 +188,79 @@ RSpec.describe 'Tags API', type: :request do Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general) Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material) Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta) - Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico) + create(:external_tag, name: 'cat_nico') get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 } expect(response).to have_http_status(:ok) - expect(response_names).to eq(%w[ - cat_deerjikist - cat_meme - cat_character - cat_general - cat_material - cat_meta - nico:cat_nico - ]) + expect(response_names).to eq([ + 'cat_deerjikist', 'cat_meme', 'cat_character', + 'cat_general', 'cat_material', 'cat_meta', 'nico:cat_nico']) + expect(json.fetch('count')).to eq(7) + end + + context 'with mixed legacy pagination' do + let!(:first_tag) do + create(:tag, + tag_name: create(:tag_name, name: 'a_mixed_page'), + category: :meme) + end + + let!(:middle_tag) do + create(:tag, + tag_name: create(:tag_name, name: 'm_mixed_page'), + category: :meta) + end + + let!(:last_tag) do + create(:tag, + tag_name: create(:tag_name, name: 'z_mixed_page'), + category: :general) + end + + let!(:first_external) do + create(:external_tag, id: first_tag.id, name: 'a_mixed_page') + end + let!(:last_external) do + create(:external_tag, id: last_tag.id, name: 'z_mixed_page') + end + + let(:name_order) do + [ + [first_tag.id, 'a_mixed_page'], [middle_tag.id, 'm_mixed_page'], + [first_external.id, 'nico:a_mixed_page'], [last_external.id, 'nico:z_mixed_page'], + [last_tag.id, 'z_mixed_page']] + end + let(:category_order) do + [ + [first_tag.id, 'a_mixed_page'], [last_tag.id, 'z_mixed_page'], + [middle_tag.id, 'm_mixed_page'], [first_external.id, 'nico:a_mixed_page'], + [last_external.id, 'nico:z_mixed_page']] + end + + ['name', 'category'].each do |order| + ['asc', 'desc'].each do |direction| + it "orders the combined records by #{ order }:#{ direction } before paging" do + ascending = order == 'name' ? name_order : category_order + expected = direction == 'asc' ? ascending : ascending.reverse + + [2, 3].each do |limit| + pages = expected.each_slice(limit).to_a + [[]] + pages.each.with_index(1) do |expected_page, page| + get '/tags', params: { + name: 'mixed_page', order: "#{ order }:#{ direction }", page:, limit: } + + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(5) + expect(response_tags.size).to eq(expected_page.size) + expect(response_tags.size).to be <= limit + expect(response_tags.map { [_1.fetch('id'), _1.fetch('name')] }) + .to eq(expected_page) + end + end + end + end + end end it 'paginates and keeps total count' do @@ -296,9 +379,125 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:not_found) end end + + it 'falls back to ExternalTag when no internal Tag has the id' do + external = create( + :external_tag, + id: Tag.maximum(:id).to_i + 10_000, + name: 'legacy_id_lookup') + + get "/tags/#{ external.id }" + + expect(response).to have_http_status(:ok) + expect(json).to include( + 'id' => external.id, + 'name' => 'nico:legacy_id_lookup', + 'category' => 'nico') + end + + it 'prefers Tag when Tag and ExternalTag have the same id' do + internal = create( + :tag, + tag_name: create(:tag_name, name: 'internal_collision')) + create( + :external_tag, + id: internal.id, + name: 'external_collision') + + get "/tags/#{ internal.id }" + + expect(response).to have_http_status(:ok) + expect(json).to include( + 'id' => internal.id, + 'name' => 'internal_collision', + 'category' => internal.category) + end end describe 'GET /tags/autocomplete' do + it 'combines internal and external matches without conflating equal ids' do + internal = Tag.create!(category: :general, name: 'mixed_internal', post_count: 2) + external = create(:external_tag, id: internal.id, + name: 'mixed_external', post_count: 3) + + get '/tags/autocomplete', params: { q: 'not:mixed' } + + expect(response).to have_http_status(:ok) + expect(json.map { |row| row.fetch('name') }) + .to eq(['nico:mixed_external', 'mixed_internal']) + expect(json.first).to include( + 'id' => external.id, 'category' => 'nico', 'post_count' => 3, + 'created_at' => external.created_at.as_json, + 'updated_at' => external.created_at.as_json, + 'deprecated_at' => nil, 'matched_alias' => nil, + 'aliases' => [], 'parents' => [], 'has_wiki' => false, + 'material_id' => nil, 'has_deerjikists' => false) + end + + it 'matches an external tag by its platform prefix' do + create(:external_tag, name: 'prefix_match', post_count: 1) + + get '/tags/autocomplete', params: { q: 'nico:prefix' } + + expect(json.map { |row| row.fetch('name') }).to eq(['nico:prefix_match']) + end + + it 'excludes unused external tags unless present is false' do + create(:external_tag, name: 'unused_external') + + get '/tags/autocomplete', params: { q: 'unused' } + expect(json).to be_empty + + get '/tags/autocomplete', params: { q: 'unused', present: '0' } + expect(json.map { |row| row.fetch('name') }).to eq(['nico:unused_external']) + end + + it 'limits the combined results to 20 and sorts ties by displayed name' do + 11.times do |i| + name = "combined_#{ i.to_s.rjust(2, '0') }" + Tag.create!(category: :general, name:, post_count: 1) + create(:external_tag, name:, post_count: 1) + end + + get '/tags/autocomplete', params: { q: 'combined' } + + expected = 11.times.map { |i| "combined_#{ i.to_s.rjust(2, '0') }" } + expected += expected.map { |name| "nico:#{ name }" } + expect(json.map { |row| row.fetch('name') }).to eq(expected.first(20)) + end + + it 'excludes external tags but preserves internal alias matches when nico is false' do + internal = Tag.create!(category: :general, name: 'switch_internal', post_count: 1) + alias_target = Tag.create!(category: :general, name: 'alias_target', post_count: 1) + TagName.create!(name: 'switch_alias', canonical: alias_target.tag_name) + create(:external_tag, name: 'switch_external', post_count: 1) + + get '/tags/autocomplete', params: { q: 'switch', nico: '0' } + + expect(response).to have_http_status(:ok) + expect(json).to contain_exactly( + a_hash_including('id' => internal.id, 'name' => internal.name), + a_hash_including('id' => alias_target.id, 'name' => alias_target.name, + 'matched_alias' => 'switch_alias')) + end + + ['%', '_'].each do |wildcard| + it "treats #{ wildcard } literally for canonical, alias, and external names" do + literal = "literal#{ wildcard }match" + Tag.create!(category: :general, name: literal, post_count: 1) + alias_target = Tag.create!(category: :general, name: 'literal_alias_target', post_count: 1) + TagName.create!(name: "#{ literal }_alias", canonical: alias_target.tag_name) + create(:external_tag, name: literal, post_count: 1) + Tag.create!(category: :general, name: 'literalXmatch', post_count: 2) + create(:external_tag, name: 'literalXmatch', post_count: 2) + + get '/tags/autocomplete', params: { q: "literal#{ wildcard }" } + + expect(json.map { |row| row.fetch('name') }) + .to contain_exactly(literal, alias_target.name, "nico:#{ literal }") + end + end + it 'returns matching tags by q' do get '/tags/autocomplete', params: { q: 'spec' } @@ -340,6 +539,27 @@ RSpec.describe 'Tags API', type: :request do end describe 'GET /tags/name/:name' do + it 'preserves qualified external name lookup used by wiki pages' do + external = create(:external_tag, name: 'detail_external', post_count: 3) + + get "/tags/name/#{ CGI.escape('nico:detail_external') }" + + expect(response).to have_http_status(:ok) + expect(json).to include( + 'id' => external.id, + 'name' => 'nico:detail_external', + 'category' => 'nico', + 'post_count' => 3, + 'created_at' => external.created_at.as_json, + 'updated_at' => external.created_at.as_json, + 'deprecated_at' => nil, + 'aliases' => [], + 'parents' => [], + 'has_wiki' => false, + 'material_id' => nil, + 'has_deerjikists' => false) + end + it 'returns tag by name' do get "/tags/name/#{ CGI.escape('spec_tag') }" @@ -487,18 +707,6 @@ RSpec.describe 'Tags API', type: :request do expect(json.fetch('deprecated_at')).to be_present end - it 'rejects deprecating a nico tag' do - nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico) - - patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' } - - expect(response).to have_http_status(:unprocessable_entity) - expect(nico_tag.reload.deprecated_at).to be_nil - expect(json.fetch('errors')).to include( - 'deprecated' => ['ニコタグは廃止できません.'] - ) - end - it 'returns 422 when changing normal tag category to nico' do expect { patch "/tags/#{tag.id}", params: { category: 'nico' } @@ -508,32 +716,6 @@ RSpec.describe 'Tags API', type: :request do expect(tag.reload.category).to eq('general') end - it 'returns 422 when updating nico tag name' do - nico_tag_name = TagName.create!(name: 'nico:tags_spec_source') - nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) - - expect { - patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' } - }.not_to change(NicoTagVersion, :count) - - expect(response).to have_http_status(:unprocessable_entity) - - expect(nico_tag.reload.name).to eq('nico:tags_spec_source') - expect(nico_tag.category).to eq('nico') - end - - it 'returns 422 when changing nico tag category to normal category' do - nico_tag_name = TagName.create!(name: 'nico:category_change_ng') - nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) - - expect { - patch "/tags/#{nico_tag.id}", params: { category: 'general' } - }.not_to change(NicoTagVersion, :count) - - expect(response).to have_http_status(:unprocessable_entity) - expect(nico_tag.reload.category).to eq('nico') - end - it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do wiki_page = Wiki::Commit.create_content!( @@ -1169,28 +1351,6 @@ RSpec.describe 'Tags API', type: :request do expect(tag.category).to eq('general') end - it 'nico tag は更新できない' do - nico_tag = Tag.create!( - tag_name: TagName.create!(name: 'nico:put_update_all_ng'), - category: :nico - ) - - expect { - put "/tags/#{ nico_tag.id }", params: { - name: 'nico:put_update_all_renamed', - category: 'nico', - aliases: '', - parent_tags: '', - deprecated: '0', - } - }.not_to change(NicoTagVersion, :count) - - expect(response).to have_http_status(:unprocessable_entity) - - expect(nico_tag.reload.name).to eq('nico:put_update_all_ng') - expect(nico_tag.category).to eq('nico') - end - it 'system tag の name は変更できない' do system_tag = Tag.tagme old_name = system_tag.name diff --git a/backend/spec/services/nico_tag_version_recorder_spec.rb b/backend/spec/services/nico_tag_version_recorder_spec.rb new file mode 100644 index 0000000..e90d1e6 --- /dev/null +++ b/backend/spec/services/nico_tag_version_recorder_spec.rb @@ -0,0 +1,76 @@ +require 'rails_helper' + +RSpec.describe NicoTagVersionRecorder do + let(:external_tag) { create(:external_tag, name: 'raw tag[]') } + let(:member) { create(:user, :member) } + + def record event_type + described_class.record!(external_tag:, event_type:, created_by_user: member) + end + + it 'records the external association and platform-qualified name' do + version = record(:create) + + expect(version).to have_attributes( + external_tag:, version_no: 1, event_type: 'create', + name: 'nico:raw tag[]', linked_tags: '', created_by_user: member) + expect(external_tag.reload.nico_tag_versions).to contain_exactly(version) + end + + it 'uses the latest history number when the record has no version_no column' do + first = record(:create) + external_tag.update!(name: 'changed') + second = record(:update) + + expect(second).to have_attributes(version_no: 2, name: 'nico:changed') + expect(first.reload.name).to eq('nico:raw tag[]') + expect(external_tag.reload.has_attribute?(:version_no)).to be(false) + end + + it 'builds the qualified name from the registered platform value' do + external = create(:external_tag, name: 'foo') + locked_scope = instance_double(ActiveRecord::Relation) + allow(ExternalTag).to receive(:unscoped).and_return(locked_scope) + allow(locked_scope).to receive(:lock).and_return(locked_scope) + allow(locked_scope).to receive(:find).with(external.id).and_return(external) + allow(external).to receive(:platform).and_return('registered_external') + + version = described_class.record!( + external_tag: external, event_type: :create, created_by_user: member) + + expect(version).to have_attributes( + external_tag: external, name: 'registered_external:foo', version_no: 1, + event_type: 'create', linked_tags: '', created_by_user: member) + end + + it 'returns the latest version without appending an unchanged snapshot' do + first = record(:create) + + expect { expect(record(:update)).to eq(first) } + .not_to change(NicoTagVersion, :count) + end + + it 'still requires a create event before any update' do + expect { record(:update) } + .to raise_error(RuntimeError, 'NicoTagVersion first event must be create') + expect(external_tag.nico_tag_versions).to be_empty + end + + it 'still rejects a second create event' do + first = record(:create) + + expect { record(:create) } + .to raise_error(RuntimeError, 'NicoTagVersion create event already exists') + expect(external_tag.nico_tag_versions).to contain_exactly(first) + end + + it 'records sorted linked internal names and later link removal' do + tags = ['z_link', 'a_link'].map { |name| Tag.create!(name:, category: :general) } + tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) } + + expect(record(:create).linked_tags).to eq('a_link z_link') + external_tag.linked_tags = [tags.first] + + expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link') + end +end diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 5558d81..71a19c1 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -50,6 +50,19 @@ RSpec.describe PostCreatePlan do expect(plan[:video_ms]).to eq(60_000) end + it 'rejects direct external tag input without creating internal records' do + create(:external_tag, name: 'reserved') + counts = [Tag.count, TagName.count, ExternalTag.count] + + expect { + described_class.new(attributes: { tags: 'NiCo:reserved' }).build! + }.to raise_error(ActiveRecord::RecordInvalid) { |error| + expect(error.record.errors[:tags]).to be_present + } + + expect([Tag.count, TagName.count, ExternalTag.count]).to eq(counts) + end + it 'validates a new tag name without persisting it' do long_name = 'a' * 256 counts = [TagName.count, Tag.count] diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index 1ec5b08..b3c053f 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -15,6 +15,20 @@ 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_pattern: '_$', + replacement: '') + + TagNameSanitisationRule.create!(priority: 45, + source_pattern: '^([^:]+\\:)?_', + replacement: '\\1') + end + it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do # 既存 post(正規表現で拾われるURL) post = Post.create!( @@ -29,7 +43,7 @@ RSpec.describe 'nico:sync' do # 追加される linked tag を準備(nico tag に紐付く一般タグ) linked = create_tag!('spec_linked', category: 'general') - nico = create_tag!('nico:AAA', category: 'nico') + nico = create_external_tag!('AAA') link_nico_to_tag!(nico, linked) # bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提) @@ -53,7 +67,7 @@ RSpec.describe 'nico:sync' do active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name') expect(active_tag_names).to include('spec_kept') - expect(active_tag_names).to include('nico:AAA') + expect(post.external_tags).to contain_exactly(nico) expect(active_tag_names).to include('spec_linked') expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z')) @@ -115,12 +129,12 @@ RSpec.describe 'nico:sync' do ) # 旧nicoタグ(今回の同期結果に含まれない) - old_nico = create_tag!('nico:OLD', category: 'nico') - PostTag.create!(post:, tag: old_nico) + old_nico = create_external_tag!('OLD') + PostExternalTag.create!(post:, external_tag: old_nico) create_post_version_for!(post) # 今回は NEW のみ欲しい - new_nico = create_tag!('nico:NEW', category: 'nico') + new_nico = create_external_tag!('NEW') # bot/tagme 念のため Tag.bot @@ -131,23 +145,21 @@ RSpec.describe 'nico:sync' do run_rake_task('nico:sync') - expect(PostTag.exists?(post:, tag: old_nico)).to be(false) + expect(PostExternalTag.exists?(post:, external_tag: old_nico)).to be(false) expect(old_nico.reload.post_count).to eq(0) expect(new_nico.reload.post_count).to eq(1) versions = post.post_versions.order(:version_no) - expect(versions.first.tags_json.map { |item| item.fetch('id') }) + expect(versions.first.tags_json.filter_map { |item| item['external_tag_id'] }) .to include(old_nico.id) - expect(versions.last.tags_json.map { |item| item.fetch('id') }) + expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] }) .to include(new_nico.id) - expect(versions.last.tags_json.map { |item| item.fetch('id') }) + expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] }) .not_to include(old_nico.id) # NEW は active にいる post.reload - active_names = post.tags.joins(:tag_name).pluck('tag_names.name') - expect(active_names).to include('nico:NEW') - expect(active_names).not_to include('nico:OLD') + expect(post.external_tags).to contain_exactly(new_nico) end def snapshot_tags(post) @@ -213,7 +225,7 @@ RSpec.describe 'nico:sync' do create_post_version_for!(post) linked = create_tag!('spec_linked', category: 'general') - nico = create_tag!('nico:AAA', category: 'nico') + nico = create_external_tag!('AAA') link_nico_to_tag!(nico, linked) Tag.bot @@ -241,7 +253,7 @@ RSpec.describe 'nico:sync' do end it '既存 post に差分が無いときは新しい version を作らない' do - nico = create_tag!('nico:AAA', category: 'nico') + nico = create_external_tag!('AAA') no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta') post = Post.create!( @@ -252,7 +264,7 @@ RSpec.describe 'nico:sync' do original_created_before: Time.iso8601('2026-01-01T03:35:00Z') ) - PostTag.create!(post: post, tag: nico) + PostExternalTag.create!(post:, external_tag: nico) PostTag.create!(post: post, tag: no_deerjikist) create_post_version_for!(post) @@ -295,7 +307,7 @@ RSpec.describe 'nico:sync' do run_rake_task('nico:sync') }.to change(NicoTagVersion, :count).by(1) - nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' }) + nico_tag = ExternalTag.find_by!(platform: :nico, name: 'AAA') version = nico_tag.nico_tag_versions.order(:version_no).last expect(version.version_no).to eq(1) @@ -378,4 +390,203 @@ RSpec.describe 'nico:sync' do expect(versions.second.title).to eq('changed title') expect(versions.second.tags).to eq(snapshot_tags(post.reload)) end + + def create_external_tag!(name) + ExternalTag.create!(platform: :nico, name:) + end + + def create_nico_sync_post! + post = Post.create!( + title: 't', + url: 'https://www.nicovideo.jp/watch/sm9', + uploaded_user: nil + ) + + PostTag.create!(post:, tag: Tag.no_deerjikist) + + post + end + + def run_nico_sync_with_tags! tags + stub_python([{ + 'code' => 'sm9', + 'title' => 't', + 'tags' => tags, + 'user' => nil + }]) + + allow(URI).to receive(:open).and_return(StringIO.new('')) + + run_rake_task('nico:sync') + end + + it '外部タグだけの変更では bot を付けず,投稿履歴を記録する' do + post = create_nico_sync_post! + PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) + + expect { + run_nico_sync_with_tags!(['raw tag[]', 'raw tag[]']) + }.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.external_tags.sole + expect(external.name).to eq('raw tag[]') + 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!(['raw tag[]']) + }.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 'サニタイズ後の既存外部タグを再利用し、その連携タグを記載する' 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) + + expect { + run_nico_sync_with_tags!(['AAA?']) + }.not_to change(ExternalTag, :count) + + 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 + post = create_nico_sync_post! + + external_tag = create_external_tag!('AAA') + old_linked_tag = create_tag!('spec_old_linked', category: :general) + new_linked_tag = create_tag!('spec_new_linked', category: :general) + + relation = link_nico_to_tag!(external_tag, old_linked_tag) + + run_nico_sync_with_tags!(['AAA']) + + expect(post.reload.tags).to include(old_linked_tag) + + # 人手で連携タグを消除する. + PostTag.find_by!(post:, tag: old_linked_tag).destroy! + + # 内外マッピングを変更する. + relation.destroy! + link_nico_to_tag!(external_tag, new_linked_tag) + + # 外部タグ自体には差分が無い. + run_nico_sync_with_tags!(['AAA']) + + post.reload + + expect(post.external_tags).to include(external_tag) + expect(post.tags).not_to include(old_linked_tag) + expect(post.tags).not_to include(new_linked_tag) + end + + it '外部タグの消除では連携タグを消除せず,再記載時にその時点の内外マッピングを適用する' do + post = create_nico_sync_post! + + external_tag = create_external_tag!('AAA') + old_linked_tag = create_tag!('spec_old_linked', category: :general) + new_linked_tag = create_tag!('spec_new_linked', category: :general) + + relation = link_nico_to_tag!(external_tag, old_linked_tag) + + # 外部タグを新規記載する. + run_nico_sync_with_tags!(['AAA']) + + post.reload + expect(post.external_tags).to include(external_tag) + expect(post.tags).to include(old_linked_tag) + + # 外部タグを消除する. + run_nico_sync_with_tags!([]) + + post.reload + expect(post.external_tags).not_to include(external_tag) + + # 外部タグの消除によって連携タグまでは消除されない. + expect(post.tags).to include(old_linked_tag) + + # 外部タグが記載されてゐない間に内外マッピングを変更する. + relation.destroy! + link_nico_to_tag!(external_tag, new_linked_tag) + + # 同じ外部タグを再記載する. + run_nico_sync_with_tags!(['AAA']) + + post.reload + + expect(post.external_tags).to include(external_tag) + + # 旧連携タグは自動的には消除されない. + expect(post.tags).to include(old_linked_tag) + + # 再記載時点の内外マッピングが新たに適用される. + 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! + create_nico_sanitisation_rules! + + 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.test.tsx b/frontend/src/components/DraggableDroppableTagRow.test.tsx index 8469831..43a32aa 100644 --- a/frontend/src/components/DraggableDroppableTagRow.test.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.test.tsx @@ -13,11 +13,14 @@ vi.mock ('@dnd-kit/core', () => dndKit) const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 }) -const renderRow = (activeDndId?: string) => { +const renderRow = ( + activeDndId?: string, + renderedTag = tag, +) => { renderWithProviders ( , @@ -72,4 +75,26 @@ describe ('DraggableDroppableTagRow', () => { renderRow ('tag-node:other') expect (tagBody ()).toHaveStyle ({ visibility: 'visible' }) }) + + it ('disables drag and drop for external tags', () => { + const external = buildTag ({ + id: 7, + name: 'nico:external', + category: 'nico', + }) + + renderRow (undefined, external) + + expect (dndKit.useDraggable).toHaveBeenCalledWith ( + expect.objectContaining ({ + disabled: true, + }), + ) + + expect (dndKit.useDroppable).toHaveBeenCalledWith ( + expect.objectContaining ({ + disabled: true, + }), + ) + }) }) 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.test.tsx b/frontend/src/components/TagDetailSidebar.test.tsx new file mode 100644 index 0000000..e89683f --- /dev/null +++ b/frontend/src/components/TagDetailSidebar.test.tsx @@ -0,0 +1,66 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import TagDetailSidebar from '@/components/TagDetailSidebar' +import { setClientTagRelationDisplayMode } from '@/lib/settings' +import { buildPost, buildTag } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +import type { ReactNode } from 'react' + +vi.mock ('@/components/TagSearch', () => ({ + default: () => null, +})) + +vi.mock ('@/components/DraggableDroppableTagRow', () => ({ + default: ({ tag }: { tag: { name: string } }) => ( + {tag.name} + ), +})) + +vi.mock ('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: ReactNode }) => <>{children}, + DragOverlay: ({ children }: { children: ReactNode }) => <>{children}, + MeasuringStrategy: { Always: 'always' }, + MouseSensor: vi.fn (), + TouchSensor: vi.fn (), + pointerWithin: vi.fn (), + useDroppable: vi.fn (() => ({ + setNodeRef: vi.fn (), + isOver: false, + })), + useSensor: vi.fn (() => ({ })), + useSensors: vi.fn (() => []), +})) + +describe ('TagDetailSidebar', () => { + beforeEach (() => { + localStorage.clear () + vi.clearAllMocks () + }) + + it ('keeps internal and external tags with the same numeric id in flat mode', () => { + setClientTagRelationDisplayMode ('flat') + + const internal = buildTag ({ + id: 7, + name: 'internal_collision', + category: 'general', + }) + const external = buildTag ({ + id: 7, + name: 'nico:external_collision', + category: 'nico', + }) + + renderWithProviders ( + , + ) + + expect (screen.getByText ('internal_collision')).toBeInTheDocument () + expect (screen.getByText ('nico:external_collision')).toBeInTheDocument () + }) +}) 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.test.tsx b/frontend/src/components/TagLink.test.tsx index e6a073c..8922265 100644 --- a/frontend/src/components/TagLink.test.tsx +++ b/frontend/src/components/TagLink.test.tsx @@ -57,4 +57,31 @@ describe ('TagLink', () => { expect (screen.getByText ('正式名')).toBeInTheDocument () expect (screen.queryByRole ('link')).not.toBeInTheDocument () }) + + it ('does not show a missing-information marker for external tags', () => { + renderWithProviders ( + , + ) + + expect ( + screen.getByRole ('link', { name: 'nico:external' }), + ).toBeInTheDocument () + + expect ( + screen.queryByRole ('link', { name: '!' }), + ).not.toBeInTheDocument () + + expect ( + screen.queryByTitle ('nico:external Wiki が存在しません.'), + ).not.toBeInTheDocument () + }) }) 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/components/common/PageTitle.tsx b/frontend/src/components/common/PageTitle.tsx index 6ae991e..8295b41 100644 --- a/frontend/src/components/common/PageTitle.tsx +++ b/frontend/src/components/common/PageTitle.tsx @@ -1,10 +1,8 @@ -import React from 'react' - import { cn } from '@/lib/utils' -import type { FC } from 'react' +import type { ComponentProps, FC } from 'react' -type Props = { children: React.ReactNode; className?: string } +type Props = ComponentProps<'h1'> const PageTitle: FC = ({ children, className, ...rest }) => ( diff --git a/frontend/src/lib/posts.test.ts b/frontend/src/lib/posts.test.ts index 600df31..972775d 100644 --- a/frontend/src/lib/posts.test.ts +++ b/frontend/src/lib/posts.test.ts @@ -116,4 +116,25 @@ describe ('posts API functions', () => { { params: { page: 2, limit: 50 } }, ) }) + + it ('maps an explicit external tag history filter to external_tag', async () => { + api.apiGet.mockResolvedValueOnce ({ versions: [], count: 0 }) + + await fetchPostChanges ({ + externalTag: '7', + page: 2, + limit: 50, + }) + + expect (api.apiGet).toHaveBeenCalledWith ( + '/posts/versions', + { + params: { + external_tag: '7', + page: 2, + limit: 50, + }, + }, + ) + }) }) 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.test.ts b/frontend/src/lib/prefetchers.test.ts index 8a341a3..b147c79 100644 --- a/frontend/src/lib/prefetchers.test.ts +++ b/frontend/src/lib/prefetchers.test.ts @@ -137,4 +137,19 @@ describe ('prefetchForURL', () => { expect (tagsApi.fetchTags).not.toHaveBeenCalled () expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled () }) + + it ('prefetches external tag post history without treating it as an internal tag', async () => { + await prefetchForURL ( + qc (), + 'http://localhost/posts/changes?external_tag=12&page=2&limit=50', + ) + + expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({ + externalTag: '12', + page: 2, + limit: 50, + }) + + expect (tagsApi.fetchTag).not.toHaveBeenCalled () + }) }) 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..a1d03d3 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 = { @@ -26,6 +30,7 @@ export const tagsKeys = { index: (p: FetchTagsParams) => ['tags', 'index', p] as const, nicoRoot: ['tags', 'nico'] as const, nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const, + externalShow: (id: string) => ['tags', 'nico', id] as const, show: (name: string) => ['tags', name] as const, changes: (p: { id?: string; page: number; limit: number }) => ['tags', 'changes', p] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 4c017ab..d27173b 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -53,6 +53,18 @@ export const fetchTag = async (id: string): Promise => { } +export const fetchExternalTag = async (id: string): Promise => { + try + { + return await apiGet (`/tags/nico/${ id }`) + } + catch + { + return null + } +} + + export const fetchTagByName = async (name: string): Promise => { try { diff --git a/frontend/src/pages/posts/PostHistoryPage.test.tsx b/frontend/src/pages/posts/PostHistoryPage.test.tsx new file mode 100644 index 0000000..b65a0b9 --- /dev/null +++ b/frontend/src/pages/posts/PostHistoryPage.test.tsx @@ -0,0 +1,63 @@ +import { screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import PostHistoryPage from '@/pages/posts/PostHistoryPage' +import { buildTag } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +const postsApi = vi.hoisted (() => ({ + fetchPostChanges: vi.fn (), + updatePost: vi.fn (), +})) + +const tagsApi = vi.hoisted (() => ({ + fetchTag: vi.fn (), + fetchExternalTag: vi.fn (), +})) + +vi.mock ('@/lib/posts', () => postsApi) +vi.mock ('@/lib/tags', () => tagsApi) + +describe ('PostHistoryPage', () => { + beforeEach (() => { + vi.clearAllMocks () + + postsApi.fetchPostChanges.mockResolvedValue ({ + versions: [], + count: 0, + }) + }) + + it ('shows the external tag name when filtering by external_tag', async () => { + const external = buildTag ({ + id: 7, + name: 'nico:external_history', + category: 'nico', + }) + + tagsApi.fetchExternalTag.mockResolvedValue (external) + + renderWithProviders ( + , + { route: '/posts/changes?external_tag=7' }, + ) + + await waitFor (() => { + expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({ + externalTag: '7', + page: 1, + limit: 20, + }) + }) + + expect (tagsApi.fetchExternalTag).toHaveBeenCalledWith ('7') + expect (tagsApi.fetchTag).not.toHaveBeenCalled () + + expect ( + await screen.findByRole ('heading', { + level: 1, + name: '耕作履歴(nico:external_history)', + }), + ).toBeInTheDocument () + }) +}) diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index 71aa3c8..446dc38 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -16,7 +16,7 @@ import { clientAnimationTransition, clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchPostChanges, updatePost } from '@/lib/posts' import { postsKeys, tagsKeys } from '@/lib/queryKeys' -import { fetchTag } from '@/lib/tags' +import { fetchExternalTag, fetchTag } from '@/lib/tags' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn, dateString, originalCreatedAtString } from '@/lib/utils' @@ -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) @@ -63,15 +64,27 @@ const PostHistoryPage: FC = () => { queryKey: tagsKeys.show (tagQueryId), queryFn: () => fetchTag (tagQueryId) }) + const externalTagQueryId = externalTagId ?? '' + const { data: externalTag } = useQuery ({ + enabled: Boolean (externalTagId), + queryKey: tagsKeys.externalShow (externalTagQueryId), + queryFn: () => fetchExternalTag (externalTagQueryId) }) + 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 + const displayedTag = externalTag ?? tag + const pageTitleLabel = `耕作履歴${ id ? `: 投稿 #${ id }` : '' }${ + displayedTag ? `(${ displayedTag.name })` : '' }` const qc = useQueryClient () @@ -134,10 +147,14 @@ const PostHistoryPage: FC = () => { {`耕作履歴 | ${ SITE_TITLE }`} - + 耕作履歴 {id && <>: 投稿 {#{id}}} - {tag && <>()} + {displayedTag && ( + <>())} {loading ? 'Loading...' : ( diff --git a/frontend/src/pages/tags/TagListPage.test.tsx b/frontend/src/pages/tags/TagListPage.test.tsx index 3d0e9ae..a2d6763 100644 --- a/frontend/src/pages/tags/TagListPage.test.tsx +++ b/frontend/src/pages/tags/TagListPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TagListPage from '@/pages/tags/TagListPage' @@ -127,4 +127,49 @@ describe ('TagListPage', () => { ) }) }) + + it ('keeps colliding internal and external tags on their own routes', async () => { + const internal = buildTag ({ + id: 7, + name: 'internal_collision', + category: 'general', + }) + const external = buildTag ({ + id: 7, + name: 'nico:external_collision', + category: 'nico', + }) + + tagsApi.fetchTags.mockResolvedValueOnce ({ + tags: [internal, external], + count: 2, + }) + + renderWithProviders (, { route: '/tags' }) + + const internalLink = + await screen.findByRole ('link', { name: 'internal_collision' }) + const externalLink = + screen.getByRole ('link', { name: 'nico:external_collision' }) + + expect (internalLink).toHaveAttribute ('href', '/tags/7') + expect (externalLink).toHaveAttribute ( + 'href', + '/tags/nico?name=nico%3Aexternal_collision', + ) + + const internalRow = internalLink.closest ('tr') + const externalRow = externalLink.closest ('tr') + + expect (internalRow).not.toBeNull () + expect (externalRow).not.toBeNull () + + expect ( + within (internalRow!).getByRole ('link', { name: '耕作履歴' }), + ).toHaveAttribute ('href', '/posts/changes?tag=7') + + expect ( + within (externalRow!).getByRole ('link', { name: '耕作履歴' }), + ).toHaveAttribute ('href', '/posts/changes?external_tag=7') + }) }) 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)} - + 耕作履歴