diff --git a/AGENTS.md b/AGENTS.md index 58422fe..cc4ad04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,11 +107,16 @@ npm run preview - Prefer single quotes for strings unless interpolation or escaping makes double quotes better. - Ruby: never put a space before method-call parentheses. +- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。 - Ruby: never put a line break immediately before `)`. - Ruby: do not use `%w` or `%i`. +- In Ruby, when an `if` condition is split across multiple lines and combines + clauses with `&&` or `||`, wrap the whole condition in parentheses. - Ruby hashes are not blocks; keep `}` on the same line as the final pair. - Ruby hashes keep the first pair on the same line as `{` unless line length requires a break. +- Short Ruby hashes may stay visually compact across two lines with the first + pair kept on the opening line and aligned continuation pairs below it. - Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body indentation. - For arrays, never put whitespace or a line break immediately before `]`. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 9085134..2953f22 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -72,17 +72,23 @@ service, representation, and spec. - Prefer precise, minimal changes. - Use single quotes unless interpolation or escaping makes double quotes better. - Do not put a space before Ruby method-call parentheses. +- For `render`-family method calls, omit parentheses even when passing + keyword arguments. - Never put a line break immediately before `)` in Ruby. - Do not use `%w` or `%i` in new Ruby code. - Never write a Ruby line longer than 99 characters. - Aim to keep Ruby lines within 79 characters where practical. - For small Ruby method definitions that take keyword arguments, match the local no-parentheses style when nearby code uses it. +- When an `if` condition is split across multiple lines and combines clauses + with `&&` or `||`, wrap the whole condition in parentheses. - Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate rules. - Do not format Ruby hashes like Ruby blocks. - For Ruby hashes, keep the closing `}` on the same line as the final pair. - Keep the first pair on the same line as `{` by default. +- Short Ruby hashes may stay visually compact across two lines with the first + pair kept on the opening line and aligned continuation pairs below it. - If the hash would exceed the line limit, break after `{` and indent pairs by 4 spaces. - Put one logical pair per line when the expression would otherwise become diff --git a/backend/app/controllers/gekanator_posts_controller.rb b/backend/app/controllers/gekanator_posts_controller.rb index e7be9b8..a176354 100644 --- a/backend/app/controllers/gekanator_posts_controller.rb +++ b/backend/app/controllers/gekanator_posts_controller.rb @@ -7,13 +7,24 @@ class GekanatorPostsController < ApplicationController .order(Arel.sql( 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \ 'posts.original_created_from, posts.created_at) DESC, posts.id DESC')) + .to_a - render json: { posts: posts.map { |post| post_json(post) } } + active_tags_by_post_id = + posts.each_with_object({ }) do |post, h| + h[post.id] = post.tags.reject(&:deprecated?) + end + + render json: { + posts: posts.map { |post| + post_json(post, + active_tags_by_post_id:) + } + } end private - def post_json post + def post_json post, active_tags_by_post_id: { id: post.id, url: post.url, @@ -22,16 +33,26 @@ class GekanatorPostsController < ApplicationController thumbnail_base: post.thumbnail_base, original_created_from: post.original_created_from, original_created_before: post.original_created_before, - post_similarity_edges: post.post_similarities.map { |similarity| - { - target_post_id: similarity.target_post_id, - cos: similarity.cos.to_f - } - }, - tags: post.tags.map { |tag| tag_json(tag) } + post_similarity_edges: post_similarity_edges_json( + post, + active_tags_by_post_id:), + tags: active_tags_by_post_id.fetch(post.id, []).map { |tag| tag_json(tag) } } end + def post_similarity_edges_json post, active_tags_by_post_id: + post + .post_similarities + .filter_map do |similarity| + next unless active_tags_by_post_id.key?(similarity.target_post_id) + + { + target_post_id: similarity.target_post_id, + cos: similarity.cos.to_f + } + end + end + def tag_json tag { id: tag.id, diff --git a/backend/app/controllers/gekanator_questions_controller.rb b/backend/app/controllers/gekanator_questions_controller.rb index d4843fd..cc08a21 100644 --- a/backend/app/controllers/gekanator_questions_controller.rb +++ b/backend/app/controllers/gekanator_questions_controller.rb @@ -5,9 +5,16 @@ class GekanatorQuestionsController < ApplicationController .accepted .includes(:gekanator_question_examples) .order(priority_weight: :desc, id: :asc) + .to_a + deprecated_tag_keys = deprecated_tag_keys_for(questions) render json: { - questions: questions.map { |question| question_json(question) } + questions: questions.filter_map { |question| + json = question_json(question) + next if hidden_question?(json[:condition], deprecated_tag_keys) + + json + } } end @@ -100,4 +107,41 @@ class GekanatorQuestionsController < ApplicationController .first &.first end + + def deprecated_tag_keys_for questions + tag_keys = questions.filter_map { |question| + condition = condition_json(question.condition) + next unless condition['type'] == 'tag' + + condition['key'].to_s.presence + }.uniq + return {} if tag_keys.empty? + + categories = [] + names = [] + tag_keys.each do |key| + category, name = parse_tag_key(key) + categories << category + names << name + end + + Tag + .joins(:tag_name) + .where(category: categories.uniq) + .where(tag_names: { name: names.uniq }) + .where.not(deprecated_at: nil) + .pluck('tags.category', 'tag_names.name') + .each_with_object({ }) do |(category, name), h| + h["#{ category }:#{ name }"] = true + end + end + + def hidden_question? condition, deprecated_tag_keys + condition[:type] == 'tag' && deprecated_tag_keys[condition[:key].to_s] + end + + def parse_tag_key key + parts = key.to_s.split(':') + [parts.first.to_s, parts.drop(1).join(':')] + end end diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index db39065..48cf816 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -148,10 +148,10 @@ class PostsController < ApplicationController ApplicationRecord.transaction do post.save! - tags = Tag.normalise_tags!(tag_names) + tags = Tag.normalise_tags!(tag_names, deny_deprecated: true) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) - tags = Tag.expand_parent_tags(tags) + tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) sync_post_tags!(post, tags) sync_parent_posts!(post, parent_post_ids) @@ -165,6 +165,8 @@ class PostsController < ApplicationController render json: PostRepr.base(post), status: :created rescue Tag::NicoTagNormalisationError render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' } + rescue Tag::DeprecatedTagNormalisationError + render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags rescue ArgumentError => e render_validation_error fields: { parent_post_ids: [e.message] } rescue ActiveRecord::RecordInvalid => e @@ -255,6 +257,8 @@ class PostsController < ApplicationController render json:, status: :ok rescue Tag::NicoTagNormalisationError render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] } + rescue Tag::DeprecatedTagNormalisationError + render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags rescue ArgumentError => e render_validation_error fields: { parent_post_ids: [e.message] } rescue ActiveRecord::RecordInvalid => e @@ -378,7 +382,7 @@ class PostsController < ApplicationController end def build_tag_tree_for tags - tags = tags.to_a + tags = tags.reject(&:deprecated?).to_a tag_ids = tags.map(&:id) implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) @@ -501,7 +505,8 @@ class PostsController < ApplicationController end def editable_tag_names_from_post post - post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') + post.tags.not_nico.where(deprecated_at: nil) + .joins(:tag_name).order('tag_names.name').pluck('tag_names.name') end def post_incoming_snapshot title:, original_created_from:, original_created_before:, @@ -533,9 +538,10 @@ class PostsController < ApplicationController end def incoming_tag_names_for_snapshot raw_tag_names - tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false) + tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false, + deny_deprecated: true) - Tag.expand_parent_tags(tags).map(&:name).uniq.sort + Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort end def post_conflict_json post:, base_version_no:, base_snapshot:, @@ -622,13 +628,14 @@ class PostsController < ApplicationController original_created_from: snapshot[:original_created_from], original_created_before: snapshot[:original_created_before]) - editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false) + editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, + deny_deprecated: true) TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) readonly_tags = post.tags.nico.to_a tags = readonly_tags + editable_tags - tags = Tag.expand_parent_tags(tags) + tags = Tag.expand_parent_tags(tags).reject(&:deprecated?) sync_post_tags!(post, tags) sync_parent_posts!(post, snapshot[:parent_post_ids]) diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index 0958c75..243a548 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -17,6 +17,7 @@ class TagVersionsController < ApplicationController AND prev.version_no = tag_versions.version_no - 1 SQL .select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category', + 'prev.deprecated_at AS prev_deprecated_at', 'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids') q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id @@ -62,6 +63,8 @@ class TagVersionsController < ApplicationController event_type: row.event_type, name: { current: row.name, prev: row.attributes['prev_name'] }, category: { current: row.category, prev: row.attributes['prev_category'] }, + deprecated_at: { current: row.deprecated_at&.iso8601, + prev: row.attributes['prev_deprecated_at']&.iso8601 }, aliases: build_version_values(cur_aliases, prev_aliases, key: :name), parent_tags:, created_at: row.created_at.iso8601, diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 30d822b..c9e585e 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -1,5 +1,6 @@ require 'net/http' require 'uri' +require 'set' class TagsController < ApplicationController @@ -14,6 +15,8 @@ class TagsController < ApplicationController post_count_between[1] = nil if post_count_between[1] < 0 created_between = params[:created_from].presence, params[:created_to].presence updated_between = params[:updated_from].presence, params[:updated_to].presence + deprecated_given = params.key?(:deprecated) + deprecated = bool?(:deprecated) order = params[:order].to_s.split(':', 2).map(&:strip) unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at']) @@ -48,6 +51,9 @@ class TagsController < ApplicationController 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) + end sort_sql = case order[0] @@ -77,37 +83,27 @@ class TagsController < ApplicationController parent_tag_id = params[:parent].to_i parent_tag_id = nil if parent_tag_id <= 0 + graph = build_with_depth_graph + tag_ids = if parent_tag_id - TagImplication.where(parent_tag_id:).select(:tag_id) + visible_child_tag_ids(parent_tag_id, graph) else - Tag.where.not(id: TagImplication.select(:tag_id)).select(:id) + visible_root_tag_ids(graph) end tags = Tag .joins(:tag_name) .includes(:tag_name, :materials, tag_name: :wiki_page) - .where(category: [:meme, :character, :material]) .where(id: tag_ids) .order('tag_names.name') .distinct .to_a - has_children_tag_ids = - if tags.empty? - [] - else - TagImplication - .joins(:tag) - .where(parent_tag_id: tags.map(&:id), - tags: { category: [:meme, :character, :material] }) - .distinct - .pluck(:parent_tag_id) - end - render json: tags.map { |tag| - TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: []) + TagRepr.base(tag).merge(has_children: visible_child_tag_ids(tag.id, graph).present?, + children: []) } end @@ -133,6 +129,7 @@ class TagsController < ApplicationController 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 = @@ -252,18 +249,24 @@ class TagsController < ApplicationController category = params[:category].to_s.strip return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank? return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank? + return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated) - if name != tag.name && - tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) - return render_unprocessable_entity('システム・タグの名称は変更できません.', field: :name) - end - - if tag.nico? || category == 'nico' - return render_unprocessable_entity('ニコタグは変更できません.', field: :category) + if (name != tag.name && + tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])) + return render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name end alias_names = params[:aliases].to_s.split.uniq 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' + return render_unprocessable_entity 'ニコタグは変更できません.', field: :category + end ApplicationRecord.transaction do TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) @@ -272,7 +275,11 @@ class TagsController < ApplicationController name_changed = name != old_name wiki_page = tag.tag_name.wiki_page if name_changed - tag.update!(category:) + if tag.deprecated? == deprecated + tag.update!(category:) + else + tag.update!(category:, deprecated_at: deprecated ? Time.current : nil) + end tag.tag_name.update!(name:) alias_names << old_name if name_changed @@ -300,11 +307,17 @@ class TagsController < ApplicationController name = params[:name].presence category = params[:category].presence + deprecated_given = params.key?(:deprecated) + deprecated = bool?(:deprecated) tag = Tag.find(params[:id]) + if tag.nico? && deprecated_given && deprecated + return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated + end + if tag.nico? || (category.present? && category == 'nico') - return render_unprocessable_entity('ニコタグは変更できません.', field: :category) + return render_unprocessable_entity 'ニコタグは変更できません.', field: :category end ApplicationRecord.transaction do @@ -316,6 +329,9 @@ class TagsController < ApplicationController tag.tag_name.update!(name:) if name.present? tag.update!(category:) if category.present? + if deprecated_given && tag.deprecated? != deprecated + tag.update!(deprecated_at: deprecated ? Time.current : nil) + end tag.reload @@ -332,6 +348,93 @@ class TagsController < ApplicationController private + def build_with_depth_graph + children_by_parent_id = Hash.new { |h, k| h[k] = [] } + parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] } + + TagImplication.pluck(:parent_tag_id, :tag_id).each do |parent_id, child_id| + children_by_parent_id[parent_id] << child_id + parent_ids_by_child_id[child_id] << parent_id + end + + tag_ids = (children_by_parent_id.keys + + parent_ids_by_child_id.keys + + Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq + + tags_by_id = Tag.where(id: tag_ids) + .pluck(:id, :category, :deprecated_at) + .each_with_object({ }) do |(id, category, deprecated_at), h| + h[id] = { category:, deprecated: deprecated_at.present? } + end + + { children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:, + visible_child_tag_ids_by_parent_id: { } } + end + + def visible_root_tag_ids graph + graph[:tags_by_id].filter_map do |tag_id, attrs| + next unless with_depth_visible_tag?(attrs) + next unless visible_root_tag?(tag_id, graph) + + tag_id + end + end + + def visible_root_tag? tag_id, graph + seen = Set.new([tag_id]) + stack = graph[:parent_ids_by_child_id][tag_id].dup + + until stack.empty? + parent_id = stack.pop + next if seen.include?(parent_id) + + seen << parent_id + + parent = graph[:tags_by_id][parent_id] + next unless parent + + return false unless parent[:deprecated] + + stack.concat(graph[:parent_ids_by_child_id][parent_id]) + end + + true + end + + def visible_child_tag_ids parent_tag_id, graph + cache = graph[:visible_child_tag_ids_by_parent_id] + return cache[parent_tag_id] if cache.key?(parent_tag_id) + + visible_ids = Set.new + + graph[:children_by_parent_id][parent_tag_id].each do |child_tag_id| + collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, Set.new([parent_tag_id])) + end + + cache[parent_tag_id] = visible_ids.to_a + end + + def collect_visible_child_tag_ids tag_id, graph, visible_ids, seen + return if seen.include?(tag_id) + + seen = seen.dup << tag_id + tag = graph[:tags_by_id][tag_id] + return unless tag + + if tag[:deprecated] + graph[:children_by_parent_id][tag_id].each do |child_tag_id| + collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, seen) + end + return + end + + visible_ids << tag_id if with_depth_visible_tag?(tag) + end + + def with_depth_visible_tag? tag + tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated] + end + def build_tag_children tag material = tag.materials.first file = nil diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 818ae02..f3ece93 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -4,17 +4,18 @@ class WikiPagesController < ApplicationController def index title = params[:title].to_s.strip if title.blank? - return render json: WikiPageRepr.base(WikiPage.joins(:tag_name).includes(:tag_name)) + return render json: WikiPageRepr.base( + WikiPage.joins(:tag_name).includes(tag_name: :tag)) end - q = WikiPage.joins(:tag_name).includes(:tag_name) + q = WikiPage.joins(:tag_name).includes(tag_name: :tag) .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") render json: WikiPageRepr.base(q.limit(20)) end def show page = WikiPage.joins(:tag_name) - .includes(:tag_name) + .includes(tag_name: :tag) .find_by(id: params[:id]) render_wiki_page_or_404 page end @@ -22,7 +23,7 @@ class WikiPagesController < ApplicationController def show_by_title title = params[:title].to_s.strip page = WikiPage.joins(:tag_name) - .includes(:tag_name) + .includes(tag_name: :tag) .find_by(tag_name: { name: title }) render_wiki_page_or_404 page end @@ -51,7 +52,7 @@ class WikiPagesController < ApplicationController from = params[:from].presence to = params[:to].presence - page = WikiPage.joins(:tag_name).includes(:tag_name).find(id) + page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) from_rev = from && page.wiki_revisions.find(from) to_rev = to ? page.wiki_revisions.find(to) : page.current_revision @@ -76,6 +77,7 @@ class WikiPagesController < ApplicationController render json: { wiki_page_id: page.id, title: page.title, + deprecated_at: page.deprecated_at, older_revision_id: from_rev&.id, newer_revision_id: to_rev.id, diff: diff_json } @@ -157,7 +159,7 @@ class WikiPagesController < ApplicationController def changes id = params[:id].presence q = WikiRevision.joins(wiki_page: :tag_name) - .includes(:created_user, wiki_page: :tag_name) + .includes(:created_user, wiki_page: { tag_name: :tag }) .order(id: :desc) q = q.where(wiki_page_id: id) if id @@ -165,7 +167,9 @@ class WikiPagesController < ApplicationController { revision_id: rev.id, pred: rev.base_revision_id, succ: nil, - wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title }, + wiki_page: { id: rev.wiki_page_id, + title: rev.wiki_page.title, + deprecated_at: rev.wiki_page.deprecated_at }, user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, kind: rev.kind, message: rev.message, diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 6a27183..d354314 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -7,6 +7,8 @@ class Post < ApplicationRecord has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' has_many :tags, through: :active_post_tags + has_many :active_tags, -> { where(tags: { deprecated_at: nil }) }, + through: :active_post_tags, source: :tag has_many :user_post_views, dependent: :delete_all has_many :post_similarities, dependent: :delete_all diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 5048e45..8e2002e 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -8,6 +8,15 @@ class Tag < ApplicationRecord ; end + class DeprecatedTagNormalisationError < ArgumentError + attr_reader :tag_names + + def initialize tag_names + @tag_names = Array(tag_names) + super('deprecated tags are not allowed') + end + end + has_many :post_tags, inverse_of: :tag has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' @@ -58,6 +67,7 @@ class Tag < ApplicationRecord validate :nico_tag_name_must_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 } @@ -77,6 +87,8 @@ class Tag < ApplicationRecord (self.tag_name ||= build_tag_name).name = val end + def deprecated? = deprecated_at? + def has_wiki = wiki_page.present? def material_id = materials.first&.id @@ -92,7 +104,8 @@ class Tag < ApplicationRecord def self.normalise_tags! tag_names, with_tagme: true, with_no_deerjikist: true, - deny_nico: true + deny_nico: true, + deny_deprecated: false if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } raise NicoTagNormalisationError end @@ -101,6 +114,10 @@ class Tag < ApplicationRecord pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| + if deny_deprecated && tag.deprecated? + raise DeprecatedTagNormalisationError, [tag.name] + end + tag.update!(category: cat) if cat && tag.category != cat end end @@ -228,4 +245,10 @@ class Tag < ApplicationRecord errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' end 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/wiki_page.rb b/backend/app/models/wiki_page.rb index efe7868..68928f1 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -22,6 +22,7 @@ class WikiPage < ApplicationRecord validates :body, presence: true def title = tag_name.name + def deprecated_at = tag_name.tag&.deprecated_at def title= val (self.tag_name ||= build_tag_name).name = val diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index e4f8198..1c62f1e 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -53,7 +53,7 @@ module PostRepr end def tag_json tags - tags.map { |tag| TagRepr.inline(tag) } + tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) } end def thumbnail_url post diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index 28be332..3958953 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -2,7 +2,7 @@ module TagRepr - BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], + BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at], methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze module_function diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index 3fb712c..ee09de3 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -2,7 +2,7 @@ module WikiPageRepr - BASE = { methods: [:title] }.freeze + BASE = { methods: [:title, :deprecated_at] }.freeze module_function diff --git a/backend/app/services/similarity/calc.rb b/backend/app/services/similarity/calc.rb index 40d717d..f320375 100644 --- a/backend/app/services/similarity/calc.rb +++ b/backend/app/services/similarity/calc.rb @@ -1,6 +1,6 @@ module Similarity class Calc - def self.call model, tgt + def self.call model, tgt, scope: nil similarity_model = "#{ model.name }Similarity".constantize # 最大保存件数 @@ -8,7 +8,8 @@ module Similarity similarity_model.delete_all - posts = model.includes(tgt).select(:id).to_a + scope ||= model.all + posts = scope.includes(tgt).select(:id).to_a tag_ids = { } tag_cnts = { } diff --git a/backend/app/services/tag_version_recorder.rb b/backend/app/services/tag_version_recorder.rb index fe2b0c1..a786058 100644 --- a/backend/app/services/tag_version_recorder.rb +++ b/backend/app/services/tag_version_recorder.rb @@ -16,6 +16,7 @@ class TagVersionRecorder < VersionRecorder def snapshot_attributes { name: @record.name, category: @record.category, + deprecated_at: @record.deprecated_at, aliases: @record.snapshot_aliases.join(' '), parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } end diff --git a/backend/db/migrate/20260621000000_add_deprecated_at_to_tags.rb b/backend/db/migrate/20260621000000_add_deprecated_at_to_tags.rb new file mode 100644 index 0000000..4456f88 --- /dev/null +++ b/backend/db/migrate/20260621000000_add_deprecated_at_to_tags.rb @@ -0,0 +1,20 @@ +class AddDeprecatedAtToTags < ActiveRecord::Migration[8.0] + def up + add_column :tags, :deprecated_at, :datetime, after: :category + add_column :tag_versions, :deprecated_at, :datetime, after: :parent_tag_ids + + add_index :tags, :deprecated_at + + add_check_constraint :tags, "deprecated_at IS NULL OR category <> 'nico'", + name: 'chk_tags_deprecated_at_not_nico' + end + + def down + remove_check_constraint :tags, name: 'chk_tags_deprecated_at_not_nico' + + remove_index :tags, :deprecated_at + + remove_column :tag_versions, :deprecated_at, :datetime + remove_column :tags, :deprecated_at + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 413adae..8c988e5 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_06_12_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) 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 @@ -319,6 +319,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do t.string "event_type", null: false t.string "name", null: false t.string "category", null: false + t.datetime "deprecated_at" t.text "aliases", null: false t.text "parent_tag_ids", null: false t.datetime "created_at", null: false @@ -336,10 +337,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "post_count", default: 0, null: false + t.datetime "deprecated_at" t.datetime "discarded_at" t.integer "version_no", null: false + t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true + t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end diff --git a/backend/lib/tasks/calc_post_similarities.rake b/backend/lib/tasks/calc_post_similarities.rake index 925770f..5f192f9 100644 --- a/backend/lib/tasks/calc_post_similarities.rake +++ b/backend/lib/tasks/calc_post_similarities.rake @@ -1,6 +1,6 @@ namespace :post_similarity do desc '関聯投稿テーブル作成' task calc: :environment do - Similarity::Calc.call(Post, :tags) + Similarity::Calc.call(Post, :active_tags) end end diff --git a/backend/lib/tasks/calc_tag_similarities.rake b/backend/lib/tasks/calc_tag_similarities.rake index 0fc718a..e029255 100644 --- a/backend/lib/tasks/calc_tag_similarities.rake +++ b/backend/lib/tasks/calc_tag_similarities.rake @@ -1,6 +1,6 @@ namespace :tag_similarity do desc '関聯タグ・テーブル作成' task calc: :environment do - Similarity::Calc.call(Tag, :posts) + Similarity::Calc.call(Tag, :posts, scope: Tag.where(deprecated_at: nil)) end end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index a6d14fd..014cdde 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -1,6 +1,79 @@ require 'rails_helper' RSpec.describe Tag, type: :model do + describe '.normalise_tags!' do + it 'rejects deprecated tags when deny_deprecated is enabled' do + tag_name = TagName.create!(name: 'normalise deprecated tag') + deprecated_tag = Tag.create!( + tag_name:, + category: :general, + deprecated_at: 1.day.from_now + ) + + expect { + described_class.normalise_tags!( + [deprecated_tag.name], + deny_deprecated: true + ) + }.to raise_error(Tag::DeprecatedTagNormalisationError) { |error| + expect(error.tag_names).to eq([deprecated_tag.name]) + } + end + end + + describe '.expand_parent_tags' do + it 'expands through multiple deprecated parents to an active ancestor' do + child = create(:tag, name: 'expand_child') + deprecated_parent = create( + :tag, + name: 'expand_deprecated_parent', + deprecated_at: Time.current + ) + deprecated_grandparent = create( + :tag, + name: 'expand_deprecated_grandparent', + deprecated_at: Time.current + ) + active_ancestor = create(:tag, name: 'expand_active_ancestor') + TagImplication.create!(tag: child, parent_tag: deprecated_parent) + TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) + TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor) + + expanded = described_class.expand_parent_tags([child]) + + expect(expanded).to include( + child, + deprecated_parent, + deprecated_grandparent, + active_ancestor + ) + expect(expanded.reject(&:deprecated?)).to contain_exactly(child, active_ancestor) + end + + it 'terminates when implications contain a cycle' do + first = create(:tag, name: 'expand_cycle_first') + second = create(:tag, name: 'expand_cycle_second') + TagImplication.create!(tag: first, parent_tag: second) + TagImplication.create!(tag: second, parent_tag: first) + + expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second) + 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 '.merge_tags!' do let!(:target_tag) { create(:tag, category: :general) } let!(:source_tag) { create(:tag, category: :general) } diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 93928d3..26f271b 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -897,6 +897,37 @@ RSpec.describe 'Gekanator learning API', type: :request do end describe 'GET /gekanator/questions' do + it 'omits questions for deprecated tags' do + active_tag = Tag.create!(name: 'active_question_tag', category: :general) + deprecated_tag = Tag.create!( + name: 'deprecated_question_tag', + category: :general, + deprecated_at: Time.current + ) + + [active_tag, deprecated_tag].each do |question_tag| + GekanatorQuestion.create!( + text: "#{ question_tag.name }?", + kind: 'tag', + source: 'admin_curated', + status: 'accepted', + priority_weight: 1.0, + condition: { + type: 'tag', + key: "#{ question_tag.category }:#{ question_tag.name }" + }, + created_by: admin + ) + end + + get '/gekanator/questions' + + expect(response).to have_http_status(:ok) + question_ids = json.fetch('questions').map { |question| question.fetch('id') } + expect(question_ids).to include('tag:general:active_question_tag') + expect(question_ids).not_to include('tag:general:deprecated_question_tag') + end + it 'returns accepted questions only and includes example_answers for post_similarity questions' do sign_in_as admin diff --git a/backend/spec/requests/gekanator_posts_spec.rb b/backend/spec/requests/gekanator_posts_spec.rb new file mode 100644 index 0000000..b21e414 --- /dev/null +++ b/backend/spec/requests/gekanator_posts_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + + +RSpec.describe 'Gekanator posts API', type: :request do + describe 'GET /gekanator/posts' do + it 'omits deprecated tags and returns the stored similarity cosine' do + active_tag = Tag.create!(name: 'active tag', category: :general) + deprecated_tag = Tag.create!( + name: 'deprecated tag', + category: :general, + deprecated_at: Time.current + ) + post_record = Post.create!(title: 'source', url: 'https://example.com/source') + target_post = Post.create!(title: 'target', url: 'https://example.com/target') + + PostTag.create!(post: post_record, tag: active_tag) + PostTag.create!(post: post_record, tag: deprecated_tag) + PostTag.create!(post: target_post, tag: deprecated_tag) + PostSimilarity.create!(post: post_record, target_post:, cos: 0.375) + + get '/gekanator/posts' + + expect(response).to have_http_status(:ok) + + post_json = json.fetch('posts').find { |post| post.fetch('id') == post_record.id } + expect(post_json.fetch('tags').map { |tag| tag.fetch('name') }).to eq(['active tag']) + expect(post_json.fetch('post_similarity_edges')).to contain_exactly( + 'target_post_id' => target_post.id, + 'cos' => 0.375 + ) + end + end +end diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 2914220..18d5b73 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -517,6 +517,24 @@ RSpec.describe 'Posts API', type: :request do expect([true, false]).to include(json['viewed']) end + it 'omits deprecated tags' do + deprecated_tag = Tag.create!( + name: 'deprecated_post_tag', + category: :general, + deprecated_at: Time.current + ) + PostTag.create!(post: post_record, tag: deprecated_tag) + + request + + expect(response).to have_http_status(:ok) + tag_names = json.fetch('tags').flat_map { |node| + [node.fetch('name')] + node.fetch('children').map { |child| child.fetch('name') } + } + expect(tag_names).to include('spec_tag') + expect(tag_names).not_to include('deprecated_post_tag') + end + context 'when post has parent, child, and sibling posts' do let!(:parent_post) do create_parent_post!( @@ -697,6 +715,58 @@ RSpec.describe 'Posts API', type: :request do expect(names).not_to include('manko') end + it 'rejects a deprecated tag specified directly' do + Tag.create!( + name: 'deprecated_direct_tag', + category: :general, + deprecated_at: Time.current + ) + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'new post', + url: 'https://example.com/deprecated-direct-tag', + tags: 'deprecated_direct_tag', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'tags' => ['廃止済みタグは付与できません.'] + ) + end + + it 'expands through multiple deprecated parent tags and saves active ancestors' do + child = Tag.create!(name: 'active_child', category: :general) + deprecated_parent = Tag.create!( + name: 'deprecated_parent', + category: :general, + deprecated_at: Time.current + ) + deprecated_grandparent = Tag.create!( + name: 'deprecated_grandparent', + category: :general, + deprecated_at: Time.current + ) + active_grandparent = Tag.create!(name: 'active_grandparent', category: :general) + TagImplication.create!(tag: child, parent_tag: deprecated_parent) + TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) + TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent) + sign_in_as(member) + + post '/posts', params: post_write_params( + title: 'expanded post', + url: 'https://example.com/expanded-deprecated-parent', + tags: 'active_child', + thumbnail: dummy_upload + ) + + expect(response).to have_http_status(:created) + saved_names = Post.find(json.fetch('id')).tags.map(&:name) + expect(saved_names).to include('active_child', 'active_grandparent') + expect(saved_names).not_to include('deprecated_parent', 'deprecated_grandparent') + end + context "when nico tag already exists in tags" do before do Tag.find_undiscard_or_create_by!( @@ -930,6 +1000,26 @@ RSpec.describe 'Posts API', type: :request do expect(names).to include('spec_tag_2') end + it 'rejects a deprecated tag specified directly' do + Tag.create!( + name: 'deprecated_update_tag', + category: :general, + deprecated_at: Time.current + ) + sign_in_as(member) + + put "/posts/#{ post_record.id }", params: post_update_params( + post_record, + title: 'updated title', + tags: 'deprecated_update_tag' + ) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'tags' => ['廃止済みタグは付与できません.'] + ) + end + context "when nico tag already exists in tags" do before do Tag.find_undiscard_or_create_by!( diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index 8f092b3..62afeb8 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -21,6 +21,7 @@ RSpec.describe 'TagVersions API', type: :request do event_type:, name:, category:, + deprecated_at: nil, aliases: [], parent_tags: [], created_by_user:, @@ -33,6 +34,7 @@ RSpec.describe 'TagVersions API', type: :request do event_type: event_type, name: name, category: category, + deprecated_at: deprecated_at, aliases: Array(aliases).join(' '), parent_tag_ids: Array(parent_tags).map(&:id).join(' '), created_by_user: created_by_user, @@ -65,6 +67,7 @@ RSpec.describe 'TagVersions API', type: :request do event_type: 'update', name: 'new_tag_name', category: 'meme', + deprecated_at: t_v2, aliases: ['alias_shared', 'alias_new'], parent_tags: [parent_shared, parent_new], created_by_user: member, @@ -133,6 +136,10 @@ RSpec.describe 'TagVersions API', type: :request do 'current' => 'meme', 'prev' => 'general' ) + expect(latest.fetch('deprecated_at')).to eq( + 'current' => t_v2.iso8601, + 'prev' => nil + ) expect(latest.fetch('aliases')).to include( { 'name' => 'alias_shared', 'type' => 'context' }, { 'name' => 'alias_new', 'type' => 'added' }, @@ -178,6 +185,10 @@ RSpec.describe 'TagVersions API', type: :request do 'current' => 'general', 'prev' => nil ) + expect(first.fetch('deprecated_at')).to eq( + 'current' => nil, + 'prev' => nil + ) expect(first.fetch('aliases')).to include( { 'name' => 'alias_shared', 'type' => 'added' }, { 'name' => 'alias_old', 'type' => 'added' } diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb index 909ebe3..4914cf4 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -89,6 +89,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do category: 'general', aliases: '', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) @@ -123,6 +124,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do category: 'meme', aliases: '', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) @@ -149,6 +151,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do category: 'general', aliases: 'put_tag_alias_only_alias', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index a688598..927b4a7 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -76,6 +76,27 @@ RSpec.describe 'Tags API', type: :request do expect(response_tags.first['id']).to eq(meme.id) end + it 'filters tags by deprecated state' do + deprecated_tag = Tag.create!( + name: 'deprecated_filter', + category: :general, + deprecated_at: 1.day.from_now + ) + active_tag = Tag.create!(name: 'active_filter', category: :general) + + get '/tags', params: { name: '_filter', deprecated: '1' } + + expect(response).to have_http_status(:ok) + expect(response_names).to include(deprecated_tag.name) + expect(response_names).not_to include(active_tag.name) + + get '/tags', params: { name: '_filter', deprecated: '0' } + + expect(response).to have_http_status(:ok) + expect(response_names).to include(active_tag.name) + expect(response_names).not_to include(deprecated_tag.name) + end + it 'filters tags by post_count range' do low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general) mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general) @@ -301,6 +322,21 @@ RSpec.describe 'Tags API', type: :request do expect(t['matched_alias']).to eq('unko') expect(json.map { |x| x['name'] }).not_to include('unknown') end + + it 'omits deprecated tags' do + deprecated_tag = Tag.create!( + name: 'spec_deprecated', + category: :general, + deprecated_at: Time.current + ) + deprecated_tag.update_columns(post_count: 1) + + get '/tags/autocomplete', params: { q: 'spec_', present: '0' } + + expect(response).to have_http_status(:ok) + expect(json.map { |item| item.fetch('name') }).to include('spec_tag') + expect(json.map { |item| item.fetch('name') }).not_to include('spec_deprecated') + end end describe 'GET /tags/name/:name' do @@ -437,6 +473,32 @@ RSpec.describe 'Tags API', type: :request do expect(versions.second.created_by_user_id).to eq(member_user.id) end + it 'updates deprecated state and records it in tag versions' do + expect { + patch "/tags/#{ tag.id }", params: { deprecated: '1' } + }.to change(TagVersion, :count).by(2) + + expect(response).to have_http_status(:ok) + expect(tag.reload.deprecated_at).to be_present + + versions = tag.tag_versions.order(:version_no) + expect(versions.first.deprecated_at).to be_nil + expect(versions.second.deprecated_at).to eq(tag.deprecated_at) + 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' } @@ -585,6 +647,111 @@ RSpec.describe 'Tags API', type: :request do expect(row['has_children']).to eq(true) expect(row['children']).to eq([]) end + + it 'passes through deprecated tags when finding children' do + deprecated_middle = Tag.create!( + name: 'depth_deprecated_middle', + category: :character, + deprecated_at: Time.current + ) + visible_descendant = Tag.create!( + name: 'depth_visible_descendant', + category: :material + ) + TagImplication.create!(parent_tag: root_material, tag: deprecated_middle) + TagImplication.create!(parent_tag: deprecated_middle, tag: visible_descendant) + + get '/tags/with-depth', params: { parent: root_material.id } + + expect(response).to have_http_status(:ok) + expect(json.map { |item| item.fetch('name') }).to eq(['depth_visible_descendant']) + expect(json.map { |item| item.fetch('name') }).not_to include('depth_deprecated_middle') + end + + it 'passes through multiple deprecated tags for roots and has_children' do + active_child = Tag.create!( + name: 'depth_active_child_below_deprecated', + category: :character + ) + deprecated_parent = Tag.create!( + name: 'depth_deprecated_parent', + category: :character, + deprecated_at: Time.current + ) + deprecated_grandparent = Tag.create!( + name: 'depth_deprecated_grandparent', + category: :material, + deprecated_at: Time.current + ) + active_ancestor = Tag.create!( + name: 'depth_active_ancestor', + category: :meme + ) + TagImplication.create!(tag: active_child, parent_tag: deprecated_parent) + TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) + TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor) + + get '/tags/with-depth' + + root_names = json.map { |item| item.fetch('name') } + expect(root_names).to include('depth_active_ancestor') + expect(root_names).not_to include('depth_active_child_below_deprecated') + ancestor_json = json.find { |item| item.fetch('id') == active_ancestor.id } + expect(ancestor_json.fetch('has_children')).to eq(true) + + get '/tags/with-depth', params: { parent: active_ancestor.id } + + expect(json.map { |item| item.fetch('name') }).to include( + 'depth_active_child_below_deprecated' + ) + expect(json.map { |item| item.fetch('name') }).not_to include( + 'depth_deprecated_parent', + 'depth_deprecated_grandparent' + ) + end + + it 'treats an active tag with only deprecated ancestors as a root' do + active_child = Tag.create!( + name: 'depth_root_below_deprecated', + category: :character + ) + deprecated_parent = Tag.create!( + name: 'depth_root_deprecated_parent', + category: :material, + deprecated_at: Time.current + ) + TagImplication.create!(tag: active_child, parent_tag: deprecated_parent) + + get '/tags/with-depth' + + expect(json.map { |item| item.fetch('name') }).to include( + 'depth_root_below_deprecated' + ) + expect(json.map { |item| item.fetch('name') }).not_to include( + 'depth_root_deprecated_parent' + ) + end + + it 'terminates when deprecated implications contain a cycle' do + first = Tag.create!( + name: 'depth_cycle_first', + category: :character, + deprecated_at: Time.current + ) + second = Tag.create!( + name: 'depth_cycle_second', + category: :material, + deprecated_at: Time.current + ) + TagImplication.create!(tag: first, parent_tag: root_material) + TagImplication.create!(tag: second, parent_tag: first) + TagImplication.create!(tag: first, parent_tag: second) + + get '/tags/with-depth', params: { parent: root_material.id } + + expect(response).to have_http_status(:ok) + expect(json).to eq([]) + end end describe 'GET /tags/name/:name/materials' do @@ -732,6 +899,20 @@ RSpec.describe 'Tags API', type: :request do expect(tag.category).to eq('general') end + it 'deprecated がなければ 422 を返す' do + put "/tags/#{ tag.id }", params: { + name: 'new', + category: 'general', + aliases: '', + parent_tags: '', + } + + expect(response).to have_http_status(:unprocessable_entity) + expect(json.fetch('errors')).to include( + 'deprecated' => ['廃止状態は必須です.'] + ) + end + it 'name, category, aliases, parent tags をまとめて更新できる' do old_parent = Tag.create!( tag_name: TagName.create!(name: 'put_old_parent'), @@ -749,6 +930,7 @@ RSpec.describe 'Tags API', type: :request do category: 'meme', aliases: 'put_alias_a put_alias_b put_alias_a', parent_tags: 'put_kept_parent put_new_parent', + deprecated: '0', } expect(response).to have_http_status(:ok) @@ -793,6 +975,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'spec_tag put_alias_self_test', parent_tags: '', + deprecated: '0', } expect(response).to have_http_status(:ok) @@ -810,6 +993,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko', parent_tags: 'spec_tag', + deprecated: '0', } expect(response).to have_http_status(:ok) @@ -825,6 +1009,7 @@ RSpec.describe 'Tags API', type: :request do category: 'meta', aliases: '', parent_tags: '', + deprecated: '0', } }.to change(TagVersion, :count).by(2) @@ -860,6 +1045,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko', parent_tags: new_parent.name, + deprecated: '0', } expect(response).to have_http_status(:ok) @@ -875,6 +1061,7 @@ RSpec.describe 'Tags API', type: :request do category: 'nico', aliases: '', parent_tags: '', + deprecated: '0', } }.not_to change(TagVersion, :count) @@ -896,6 +1083,7 @@ RSpec.describe 'Tags API', type: :request do category: 'nico', aliases: '', parent_tags: '', + deprecated: '0', } }.not_to change(NicoTagVersion, :count) @@ -916,6 +1104,7 @@ RSpec.describe 'Tags API', type: :request do category: old_category, aliases: '', parent_tags: '', + deprecated: '0', } }.not_to change(TagVersion, :count) @@ -946,6 +1135,7 @@ RSpec.describe 'Tags API', type: :request do category: 'meme', aliases: 'unko', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) @@ -981,6 +1171,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko put_stolen_alias', parent_tags: '', + deprecated: '0', } } .to change { tag.reload.tag_versions.count }.by(2) @@ -1015,6 +1206,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko', parent_tags: child.name, + deprecated: '0', } expect(response).to have_http_status(:unprocessable_entity) @@ -1036,6 +1228,7 @@ RSpec.describe 'Tags API', type: :request do category: 'general', aliases: 'unko', parent_tags: '', + deprecated: '0', } } .to change(TagVersion, :count).by(2) diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 9b6e8c1..614180e 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -18,6 +18,13 @@ RSpec.describe 'Wiki API', type: :request do created_by_user: user, message: 'init') end + let!(:tag) do + Tag.create!( + tag_name: tn, + category: :general, + deprecated_at: Time.zone.local(2026, 6, 1) + ) + end describe 'GET /wiki' do it 'returns wiki pages with title' do @@ -30,6 +37,8 @@ RSpec.describe 'Wiki API', type: :request do expect(json[0]).to have_key('title') expect(json.map { |p| p['title'] }).to include('spec_wiki_title') + wiki_json = json.find { |item| item.fetch('id') == page.id } + expect(wiki_json.fetch('deprecated_at')).to eq(tag.deprecated_at.iso8601(3)) end end @@ -48,7 +57,8 @@ RSpec.describe 'Wiki API', type: :request do expect(json).to include( 'id' => page.id, - 'title' => 'spec_wiki_title') + 'title' => 'spec_wiki_title', + 'deprecated_at' => tag.deprecated_at.iso8601(3)) end end @@ -409,7 +419,11 @@ RSpec.describe 'Wiki API', type: :request do 'kind' => 'content', 'message' => 'r2' ) - expect(top['wiki_page']).to include('id' => page.id, 'title' => 'spec_wiki_title') + expect(top['wiki_page']).to include( + 'id' => page.id, + 'title' => 'spec_wiki_title', + 'deprecated_at' => tag.deprecated_at.iso8601(3) + ) expect(top['user']).to include('id' => user.id, 'name' => user.name) expect(top).to have_key('timestamp') @@ -479,6 +493,7 @@ RSpec.describe 'Wiki API', type: :request do expect(json).to include( 'wiki_page_id' => page.id, 'title' => 'spec_wiki_title', + 'deprecated_at' => tag.deprecated_at.iso8601(3), 'older_revision_id' => rev_a.id, 'newer_revision_id' => rev_b.id ) diff --git a/backend/spec/tasks/post_similarity_calc_spec.rb b/backend/spec/tasks/post_similarity_calc_spec.rb index 41de663..20dd575 100644 --- a/backend/spec/tasks/post_similarity_calc_spec.rb +++ b/backend/spec/tasks/post_similarity_calc_spec.rb @@ -4,11 +4,12 @@ require 'rails_helper' RSpec.describe 'post_similarity:calc' do include RakeTaskHelper - it 'calls Similarity::Calc with Post and :tags' do + it 'calculates similarities from active tags only' do # 必要最低限のデータ t1 = Tag.create!(name: "t1") t2 = Tag.create!(name: "t2") t3 = Tag.create!(name: "t3") + deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current) p1 = Post.create!(url: "https://example.com/1") p2 = Post.create!(url: "https://example.com/2") @@ -22,6 +23,8 @@ RSpec.describe 'post_similarity:calc' do PostTag.create!(post: p2, tag: t3) PostTag.create!(post: p3, tag: t3) + PostTag.create!(post: p1, tag: deprecated_tag) + PostTag.create!(post: p2, tag: deprecated_tag) expect { run_rake_task("post_similarity:calc") } .to change { PostSimilarity.count }.from(0) @@ -29,6 +32,6 @@ RSpec.describe 'post_similarity:calc' do ps = PostSimilarity.find_by!(post_id: p1.id, target_post_id: p2.id) ps_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id) expect(ps_rev.cos).to eq(ps.cos) + expect(ps.cos).to be_within(0.0001).of(0.5) end end - diff --git a/backend/spec/tasks/tag_similarity_calc_spec.rb b/backend/spec/tasks/tag_similarity_calc_spec.rb index 8022231..3063010 100644 --- a/backend/spec/tasks/tag_similarity_calc_spec.rb +++ b/backend/spec/tasks/tag_similarity_calc_spec.rb @@ -4,11 +4,12 @@ require 'rails_helper' RSpec.describe 'tag_similarity:calc' do include RakeTaskHelper - it 'calls Similarity::Calc with Tag and :posts' do + it 'calculates similarities for active tags only' do # 必要最低限のデータ t1 = Tag.create!(name: "t1") t2 = Tag.create!(name: "t2") t3 = Tag.create!(name: "t3") + deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current) p1 = Post.create!(url: "https://example.com/1") p2 = Post.create!(url: "https://example.com/2") @@ -22,6 +23,7 @@ RSpec.describe 'tag_similarity:calc' do PostTag.create!(post: p2, tag: t3) PostTag.create!(post: p3, tag: t3) + PostTag.create!(post: p1, tag: deprecated_tag) expect { run_rake_task("tag_similarity:calc") } .to change { TagSimilarity.count }.from(0) @@ -29,6 +31,7 @@ RSpec.describe 'tag_similarity:calc' do ps = TagSimilarity.find_by!(tag_id: t1.id, target_tag_id: t2.id) ps_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id) expect(ps_rev.cos).to eq(ps.cos) + expect(TagSimilarity.where(tag_id: deprecated_tag.id)).to be_empty + expect(TagSimilarity.where(target_tag_id: deprecated_tag.id)).to be_empty end end - diff --git a/frontend/src/components/TagLink.test.tsx b/frontend/src/components/TagLink.test.tsx index 3c2a152..e6a073c 100644 --- a/frontend/src/components/TagLink.test.tsx +++ b/frontend/src/components/TagLink.test.tsx @@ -18,6 +18,21 @@ describe ('TagLink', () => { expect (screen.getByText ('4')).toBeInTheDocument () }) + it ('does not append deprecated state to the rendered tag name', () => { + renderWithProviders ( + , + ) + + expect (screen.getByRole ('link', { name: '旧タグ' })).toBeInTheDocument () + expect (screen.queryByText ('(廃止)')).not.toBeInTheDocument () + }) + it ('links wiki markers to the correct detail route', () => { renderWithProviders ( , diff --git a/frontend/src/components/TagLink.tsx b/frontend/src/components/TagLink.tsx index 33bfc40..4abdf1a 100644 --- a/frontend/src/components/TagLink.tsx +++ b/frontend/src/components/TagLink.tsx @@ -128,4 +128,4 @@ const TagLink: FC = ({ tag, ) } -export default TagLink \ No newline at end of file +export default TagLink diff --git a/frontend/src/lib/gekanator.test.ts b/frontend/src/lib/gekanator.test.ts index dffdbd4..bf8947f 100644 --- a/frontend/src/lib/gekanator.test.ts +++ b/frontend/src/lib/gekanator.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { apiPost } from '@/lib/api' +import { apiGet, apiPost } from '@/lib/api' import { buildGekanatorQuestions, expectedAnswerForQuestion, + fetchGekanatorPosts, + fetchGekanatorQuestions, learnedSemanticSideForPost, questionIdForCondition, restoreGekanatorQuestion, @@ -24,6 +26,7 @@ vi.mock('@/lib/api', () => ({ })) const mockedApiPost = vi.mocked(apiPost) +const mockedApiGet = vi.mocked(apiGet) const post = (overrides: Partial = {}): Post => ({ id: 1, @@ -43,6 +46,24 @@ const post = (overrides: Partial = {}): Post => ({ ...overrides, }) +describe('Gekanator API functions', () => { + it('returns posts from the Gekanator posts endpoint', async () => { + const posts = [post()] + mockedApiGet.mockResolvedValueOnce({ posts }) + + await expect(fetchGekanatorPosts()).resolves.toEqual(posts) + expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts') + }) + + it('returns questions from the Gekanator questions endpoint', async () => { + const questions: StoredGekanatorQuestion[] = [] + mockedApiGet.mockResolvedValueOnce({ questions }) + + await expect(fetchGekanatorQuestions()).resolves.toEqual(questions) + expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions') + }) +}) + describe('expectedAnswerForQuestion', () => { it('returns a direct example answer when present', () => { const question: StoredGekanatorQuestion = { @@ -126,6 +147,7 @@ describe('expectedAnswerForQuestion', () => { postCount: 1, createdAt: '2026-06-10T00:00:00.000Z', updatedAt: '2026-06-10T00:00:00.000Z', + deprecatedAt: null, hasWiki: false, hasDeerjikists: false, materialId: null, diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 38f51cc..264a380 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -17,6 +17,10 @@ const mWiki = match<{ title: string }> ('/wiki/:title') const mTag = match<{ id: string }> ('/tags/:id') +const boolFromQuery = (value: string | null): boolean => + ['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ()) + + const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { const title = url.searchParams.get ('title') ?? '' @@ -156,13 +160,16 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => { const createdTo = url.searchParams.get ('created_to') ?? '' const updatedFrom = url.searchParams.get ('updated_from') ?? '' const updatedTo = url.searchParams.get ('updated_to') ?? '' + const deprecated = url.searchParams.has ('deprecated') + ? boolFromQuery (url.searchParams.get ('deprecated')) + : null const page = Number (url.searchParams.get ('page') || 1) const limit = Number (url.searchParams.get ('limit') || 20) const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder const keys = { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, - updatedFrom, updatedTo, page, limit, order } + updatedFrom, updatedTo, deprecated, page, limit, order } await qc.prefetchQuery ({ queryKey: tagsKeys.index (keys), diff --git a/frontend/src/lib/tags.test.ts b/frontend/src/lib/tags.test.ts index f51455c..7075997 100644 --- a/frontend/src/lib/tags.test.ts +++ b/frontend/src/lib/tags.test.ts @@ -20,6 +20,7 @@ const baseParams: FetchTagsParams = { createdTo: '', updatedFrom: '', updatedTo: '', + deprecated: null, page: 1, limit: 30, order: 'updated_at:desc', @@ -57,6 +58,20 @@ describe ('tags API functions', () => { ) }) + it.each ([ + [true, '1'], + [false, '0'], + ] as const) ('maps deprecated=%s to %s', async (deprecated, expected) => { + api.apiGet.mockResolvedValueOnce ({ tags: [], count: 0 }) + + await fetchTags ({ ...baseParams, deprecated }) + + expect (api.apiGet).toHaveBeenCalledWith ( + '/tags', + { params: expect.objectContaining ({ deprecated: expected }) }, + ) + }) + it ('returns null when tag fetches fail', async () => { api.apiGet.mockRejectedValueOnce (new Error ('missing')) api.apiGet.mockRejectedValueOnce (new Error ('missing')) diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 9ac8788..31cad85 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -10,7 +10,8 @@ import type { Deerjikist, export const fetchTags = async ( { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, - updatedFrom, updatedTo, page, limit, order }: FetchTagsParams, + updatedFrom, updatedTo, deprecated, + page, limit, order }: FetchTagsParams, ): Promise<{ tags: Tag[] count: number }> => await apiGet ('/tags', { params: { @@ -23,6 +24,7 @@ export const fetchTags = async ( ...(createdTo && { created_to: createdTo }), ...(updatedFrom && { updated_from: updatedFrom }), ...(updatedTo && { updated_to: updatedTo }), + ...(deprecated != null && { deprecated: deprecated ? '1' : '0' }), ...(page && { page }), ...(limit && { limit }), ...(order && { order }) } }) @@ -64,7 +66,6 @@ export const fetchTagByName = async (name: string): Promise => { } } - export const fetchTagChanges = async ( { id, page, limit }: { id?: string diff --git a/frontend/src/pages/tags/TagDetailPage.tsx b/frontend/src/pages/tags/TagDetailPage.tsx index 386208e..d1e3e76 100644 --- a/frontend/src/pages/tags/TagDetailPage.tsx +++ b/frontend/src/pages/tags/TagDetailPage.tsx @@ -19,7 +19,12 @@ import type { FC, FormEvent } from 'react' import type { Category, Tag } from '@/types' -type TagFormField = 'name' | 'category' | 'aliases' | 'parentTags' +type TagFormField = + | 'name' + | 'category' + | 'aliases' + | 'parentTags' + | 'deprecated' const TagDetailPage: FC = () => { @@ -35,6 +40,7 @@ const TagDetailPage: FC = () => { const [category, setCategory] = useState ('general') const [aliases, setAliases] = useState ('') const [parentTags, setParentTags] = useState ('') + const [deprecated, setDeprecated] = useState (false) const [disabled, setDisabled] = useState (true) const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () @@ -50,6 +56,7 @@ const TagDetailPage: FC = () => { formData.append ('category', category) formData.append ('aliases', aliases) formData.append ('parent_tags', parentTags) + formData.append ('deprecated', deprecated ? '1' : '0') try { @@ -59,6 +66,7 @@ const TagDetailPage: FC = () => { setCategory (data.category as Category) setAliases (data.aliases.join (' ')) setParentTags (data.parents.map (t => t.name).join (' ')) + setDeprecated (Boolean (data.deprecatedAt)) qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) @@ -82,6 +90,7 @@ const TagDetailPage: FC = () => { setCategory (tag.category as Category) setAliases (tag.aliases.join (' ')) setParentTags (tag.parents.map (t => t.name).join (' ')) + setDeprecated (Boolean (tag.deprecatedAt)) setDisabled (tag.category === 'nico') }, [tag]) @@ -165,6 +174,17 @@ const TagDetailPage: FC = () => { )} + + {({ describedBy, invalid }) => ( + setDeprecated (e.target.checked)} + aria-describedby={describedBy} + aria-invalid={invalid}/>)} + +