タグ “廃止” 追加 (#378) #379

マージ済み
みてるぞ が 7 個のコミットを feature/378 から main へマージ 2026-06-22 08:40:07 +09:00
51個のファイルの変更1095行の追加100行の削除
+5
ファイルの表示
@@ -107,11 +107,16 @@ npm run preview
- Prefer single quotes for strings unless interpolation or escaping makes - Prefer single quotes for strings unless interpolation or escaping makes
double quotes better. double quotes better.
- Ruby: never put a space before method-call parentheses. - Ruby: never put a space before method-call parentheses.
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
- Ruby: never put a line break immediately before `)`. - Ruby: never put a line break immediately before `)`.
- Ruby: do not use `%w` or `%i`. - 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 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 - Ruby hashes keep the first pair on the same line as `{` unless line length
requires a break. 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 - Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
indentation. indentation.
- For arrays, never put whitespace or a line break immediately before `]`. - For arrays, never put whitespace or a line break immediately before `]`.
+6
ファイルの表示
@@ -72,17 +72,23 @@ service, representation, and spec.
- Prefer precise, minimal changes. - Prefer precise, minimal changes.
- Use single quotes unless interpolation or escaping makes double quotes better. - Use single quotes unless interpolation or escaping makes double quotes better.
- Do not put a space before Ruby method-call parentheses. - 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. - Never put a line break immediately before `)` in Ruby.
- Do not use `%w` or `%i` in new Ruby code. - Do not use `%w` or `%i` in new Ruby code.
- Never write a Ruby line longer than 99 characters. - Never write a Ruby line longer than 99 characters.
- Aim to keep Ruby lines within 79 characters where practical. - Aim to keep Ruby lines within 79 characters where practical.
- For small Ruby method definitions that take keyword arguments, match the - For small Ruby method definitions that take keyword arguments, match the
local no-parentheses style when nearby code uses it. 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 - Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
rules. rules.
- Do not format Ruby hashes like Ruby blocks. - Do not format Ruby hashes like Ruby blocks.
- For Ruby hashes, keep the closing `}` on the same line as the final pair. - 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. - 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 - If the hash would exceed the line limit, break after `{` and indent pairs
by 4 spaces. by 4 spaces.
- Put one logical pair per line when the expression would otherwise become - Put one logical pair per line when the expression would otherwise become
+30 -9
ファイルの表示
@@ -7,13 +7,24 @@ class GekanatorPostsController < ApplicationController
.order(Arel.sql( .order(Arel.sql(
'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \ 'COALESCE(posts.original_created_before - INTERVAL 1 MINUTE, ' \
'posts.original_created_from, posts.created_at) DESC, posts.id DESC')) '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 end
private private
def post_json post def post_json post, active_tags_by_post_id:
{ {
id: post.id, id: post.id,
url: post.url, url: post.url,
@@ -22,16 +33,26 @@ class GekanatorPostsController < ApplicationController
thumbnail_base: post.thumbnail_base, thumbnail_base: post.thumbnail_base,
original_created_from: post.original_created_from, original_created_from: post.original_created_from,
original_created_before: post.original_created_before, original_created_before: post.original_created_before,
post_similarity_edges: post.post_similarities.map { |similarity| post_similarity_edges: post_similarity_edges_json(
{ post,
target_post_id: similarity.target_post_id, active_tags_by_post_id:),
cos: similarity.cos.to_f tags: active_tags_by_post_id.fetch(post.id, []).map { |tag| tag_json(tag) }
}
},
tags: post.tags.map { |tag| tag_json(tag) }
} }
end 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 def tag_json tag
{ {
id: tag.id, id: tag.id,
+45 -1
ファイルの表示
@@ -5,9 +5,16 @@ class GekanatorQuestionsController < ApplicationController
.accepted .accepted
.includes(:gekanator_question_examples) .includes(:gekanator_question_examples)
.order(priority_weight: :desc, id: :asc) .order(priority_weight: :desc, id: :asc)
.to_a
deprecated_tag_keys = deprecated_tag_keys_for(questions)
render json: { 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 end
@@ -100,4 +107,41 @@ class GekanatorQuestionsController < ApplicationController
.first .first
&.first &.first
end 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 end
+15 -8
ファイルの表示
@@ -148,10 +148,10 @@ class PostsController < ApplicationController
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! 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) 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_post_tags!(post, tags)
sync_parent_posts!(post, parent_post_ids) sync_parent_posts!(post, parent_post_ids)
@@ -165,6 +165,8 @@ class PostsController < ApplicationController
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' } render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -255,6 +257,8 @@ class PostsController < ApplicationController
render json:, status: :ok render json:, status: :ok
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] } render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render_validation_error fields: { parent_post_ids: [e.message] }
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -378,7 +382,7 @@ class PostsController < ApplicationController
end end
def build_tag_tree_for tags def build_tag_tree_for tags
tags = tags.to_a tags = tags.reject(&:deprecated?).to_a
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
@@ -501,7 +505,8 @@ class PostsController < ApplicationController
end end
def editable_tag_names_from_post post 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 end
def post_incoming_snapshot title:, original_created_from:, original_created_before:, def post_incoming_snapshot title:, original_created_from:, original_created_before:,
@@ -533,9 +538,10 @@ class PostsController < ApplicationController
end end
def incoming_tag_names_for_snapshot raw_tag_names 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 end
def post_conflict_json post:, base_version_no:, base_snapshot:, 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_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before]) 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) TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags 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_post_tags!(post, tags)
sync_parent_posts!(post, snapshot[:parent_post_ids]) sync_parent_posts!(post, snapshot[:parent_post_ids])
+3
ファイルの表示
@@ -17,6 +17,7 @@ class TagVersionsController < ApplicationController
AND prev.version_no = tag_versions.version_no - 1 AND prev.version_no = tag_versions.version_no - 1
SQL SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category', .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') '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 q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
@@ -62,6 +63,8 @@ class TagVersionsController < ApplicationController
event_type: row.event_type, event_type: row.event_type,
name: { current: row.name, prev: row.attributes['prev_name'] }, name: { current: row.name, prev: row.attributes['prev_name'] },
category: { current: row.category, prev: row.attributes['prev_category'] }, 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), aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
parent_tags:, parent_tags:,
created_at: row.created_at.iso8601, created_at: row.created_at.iso8601,
+128 -25
ファイルの表示
@@ -1,5 +1,6 @@
require 'net/http' require 'net/http'
require 'uri' require 'uri'
require 'set'
class TagsController < ApplicationController class TagsController < ApplicationController
@@ -14,6 +15,8 @@ class TagsController < ApplicationController
post_count_between[1] = nil if post_count_between[1] < 0 post_count_between[1] = nil if post_count_between[1] < 0
created_between = params[:created_from].presence, params[:created_to].presence created_between = params[:created_from].presence, params[:created_to].presence
updated_between = params[:updated_from].presence, params[:updated_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) order = params[:order].to_s.split(':', 2).map(&:strip)
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at']) 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.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[0]) if updated_between[0]
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1] 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 = sort_sql =
case order[0] case order[0]
@@ -77,37 +83,27 @@ class TagsController < ApplicationController
parent_tag_id = params[:parent].to_i parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0 parent_tag_id = nil if parent_tag_id <= 0
graph = build_with_depth_graph
tag_ids = tag_ids =
if parent_tag_id if parent_tag_id
TagImplication.where(parent_tag_id:).select(:tag_id) visible_child_tag_ids(parent_tag_id, graph)
else else
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id) visible_root_tag_ids(graph)
end end
tags = tags =
Tag Tag
.joins(:tag_name) .joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
.where(category: [:meme, :character, :material])
.where(id: tag_ids) .where(id: tag_ids)
.order('tag_names.name') .order('tag_names.name')
.distinct .distinct
.to_a .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| 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 end
@@ -133,6 +129,7 @@ class TagsController < ApplicationController
base = Tag.joins(:tag_name) base = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only base = base.where('tags.post_count > 0') if present_only
canonical_hit = canonical_hit =
@@ -252,18 +249,24 @@ class TagsController < ApplicationController
category = params[:category].to_s.strip category = params[:category].to_s.strip
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank? return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank? return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
if name != tag.name && if (name != tag.name &&
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]))
return render_unprocessable_entity('システム・タグの名称は変更できません.', field: :name) return render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
end
if tag.nico? || category == 'nico'
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
end end
alias_names = params[:aliases].to_s.split.uniq alias_names = params[:aliases].to_s.split.uniq
parent_names = params[:parent_tags].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 ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
@@ -272,7 +275,11 @@ class TagsController < ApplicationController
name_changed = name != old_name name_changed = name != old_name
wiki_page = tag.tag_name.wiki_page if name_changed 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:) tag.tag_name.update!(name:)
alias_names << old_name if name_changed alias_names << old_name if name_changed
@@ -300,11 +307,17 @@ class TagsController < ApplicationController
name = params[:name].presence name = params[:name].presence
category = params[:category].presence category = params[:category].presence
deprecated_given = params.key?(:deprecated)
deprecated = bool?(:deprecated)
tag = Tag.find(params[:id]) 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') if tag.nico? || (category.present? && category == 'nico')
return render_unprocessable_entity('ニコタグは変更できません.', field: :category) return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end end
ApplicationRecord.transaction do ApplicationRecord.transaction do
@@ -316,6 +329,9 @@ class TagsController < ApplicationController
tag.tag_name.update!(name:) if name.present? tag.tag_name.update!(name:) if name.present?
tag.update!(category:) if category.present? tag.update!(category:) if category.present?
if deprecated_given && tag.deprecated? != deprecated
tag.update!(deprecated_at: deprecated ? Time.current : nil)
end
tag.reload tag.reload
@@ -332,6 +348,93 @@ class TagsController < ApplicationController
private 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 def build_tag_children tag
material = tag.materials.first material = tag.materials.first
file = nil file = nil
+11 -7
ファイルの表示
@@ -4,17 +4,18 @@ class WikiPagesController < ApplicationController
def index def index
title = params[:title].to_s.strip title = params[:title].to_s.strip
if title.blank? 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 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) }%") .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
render json: WikiPageRepr.base(q.limit(20)) render json: WikiPageRepr.base(q.limit(20))
end end
def show def show
page = WikiPage.joins(:tag_name) page = WikiPage.joins(:tag_name)
.includes(:tag_name) .includes(tag_name: :tag)
.find_by(id: params[:id]) .find_by(id: params[:id])
render_wiki_page_or_404 page render_wiki_page_or_404 page
end end
@@ -22,7 +23,7 @@ class WikiPagesController < ApplicationController
def show_by_title def show_by_title
title = params[:title].to_s.strip title = params[:title].to_s.strip
page = WikiPage.joins(:tag_name) page = WikiPage.joins(:tag_name)
.includes(:tag_name) .includes(tag_name: :tag)
.find_by(tag_name: { name: title }) .find_by(tag_name: { name: title })
render_wiki_page_or_404 page render_wiki_page_or_404 page
end end
@@ -51,7 +52,7 @@ class WikiPagesController < ApplicationController
from = params[:from].presence from = params[:from].presence
to = params[:to].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) from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision 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, render json: { wiki_page_id: page.id,
title: page.title, title: page.title,
deprecated_at: page.deprecated_at,
older_revision_id: from_rev&.id, older_revision_id: from_rev&.id,
newer_revision_id: to_rev.id, newer_revision_id: to_rev.id,
diff: diff_json } diff: diff_json }
@@ -157,7 +159,7 @@ class WikiPagesController < ApplicationController
def changes def changes
id = params[:id].presence id = params[:id].presence
q = WikiRevision.joins(wiki_page: :tag_name) 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) .order(id: :desc)
q = q.where(wiki_page_id: id) if id q = q.where(wiki_page_id: id) if id
@@ -165,7 +167,9 @@ class WikiPagesController < ApplicationController
{ revision_id: rev.id, { revision_id: rev.id,
pred: rev.base_revision_id, pred: rev.base_revision_id,
succ: nil, 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 }, user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
kind: rev.kind, kind: rev.kind,
message: rev.message, message: rev.message,
+2
ファイルの表示
@@ -7,6 +7,8 @@ class Post < ApplicationRecord
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post 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 :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags 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 :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
+24 -1
ファイルの表示
@@ -8,6 +8,15 @@ class Tag < ApplicationRecord
; ;
end 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 :post_tags, inverse_of: :tag
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', 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' 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 :nico_tag_name_must_start_with_nico
validate :tag_name_must_be_canonical validate :tag_name_must_be_canonical
validate :category_must_be_deerjikist_with_deerjikists validate :category_must_be_deerjikist_with_deerjikists
validate :nico_tags_cannot_be_deprecated
scope :nico_tags, -> { nico } scope :nico_tags, -> { nico }
@@ -77,6 +87,8 @@ class Tag < ApplicationRecord
(self.tag_name ||= build_tag_name).name = val (self.tag_name ||= build_tag_name).name = val
end end
def deprecated? = deprecated_at?
def has_wiki = wiki_page.present? def has_wiki = wiki_page.present?
def material_id = materials.first&.id def material_id = materials.first&.id
@@ -92,7 +104,8 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true, def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: 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:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
@@ -101,6 +114,10 @@ class Tag < ApplicationRecord
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| 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 tag.update!(category: cat) if cat && tag.category != cat
end end
end end
@@ -228,4 +245,10 @@ class Tag < ApplicationRecord
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
end end
end end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
end
end
end end
+1
ファイルの表示
@@ -22,6 +22,7 @@ class WikiPage < ApplicationRecord
validates :body, presence: true validates :body, presence: true
def title = tag_name.name def title = tag_name.name
def deprecated_at = tag_name.tag&.deprecated_at
def title= val def title= val
(self.tag_name ||= build_tag_name).name = val (self.tag_name ||= build_tag_name).name = val
+1 -1
ファイルの表示
@@ -53,7 +53,7 @@ module PostRepr
end end
def tag_json tags def tag_json tags
tags.map { |tag| TagRepr.inline(tag) } tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) }
end end
def thumbnail_url post def thumbnail_url post
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@
module TagRepr 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 methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
module_function module_function
+1 -1
ファイルの表示
@@ -2,7 +2,7 @@
module WikiPageRepr module WikiPageRepr
BASE = { methods: [:title] }.freeze BASE = { methods: [:title, :deprecated_at] }.freeze
module_function module_function
+3 -2
ファイルの表示
@@ -1,6 +1,6 @@
module Similarity module Similarity
class Calc class Calc
def self.call model, tgt def self.call model, tgt, scope: nil
similarity_model = "#{ model.name }Similarity".constantize similarity_model = "#{ model.name }Similarity".constantize
# 最大保存件数 # 最大保存件数
@@ -8,7 +8,8 @@ module Similarity
similarity_model.delete_all 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_ids = { }
tag_cnts = { } tag_cnts = { }
+1
ファイルの表示
@@ -16,6 +16,7 @@ class TagVersionRecorder < VersionRecorder
def snapshot_attributes def snapshot_attributes
{ name: @record.name, { name: @record.name,
category: @record.category, category: @record.category,
deprecated_at: @record.deprecated_at,
aliases: @record.snapshot_aliases.join(' '), aliases: @record.snapshot_aliases.join(' '),
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end end
+20
ファイルの表示
@@ -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
生成ファイル
+5 -1
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", 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 "event_type", null: false
t.string "name", null: false t.string "name", null: false
t.string "category", null: false t.string "category", null: false
t.datetime "deprecated_at"
t.text "aliases", null: false t.text "aliases", null: false
t.text "parent_tag_ids", null: false t.text "parent_tag_ids", null: false
t.datetime "created_at", 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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.datetime "deprecated_at"
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "version_no", null: false 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 ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true 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" t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end end
+1 -1
ファイルの表示
@@ -1,6 +1,6 @@
namespace :post_similarity do namespace :post_similarity do
desc '関聯投稿テーブル作成' desc '関聯投稿テーブル作成'
task calc: :environment do task calc: :environment do
Similarity::Calc.call(Post, :tags) Similarity::Calc.call(Post, :active_tags)
end end
end end
+1 -1
ファイルの表示
@@ -1,6 +1,6 @@
namespace :tag_similarity do namespace :tag_similarity do
desc '関聯タグ・テーブル作成' desc '関聯タグ・テーブル作成'
task calc: :environment do task calc: :environment do
Similarity::Calc.call(Tag, :posts) Similarity::Calc.call(Tag, :posts, scope: Tag.where(deprecated_at: nil))
end end
end end
+73
ファイルの表示
@@ -1,6 +1,79 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Tag, type: :model do 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 describe '.merge_tags!' do
let!(:target_tag) { create(:tag, category: :general) } let!(:target_tag) { create(:tag, category: :general) }
let!(:source_tag) { create(:tag, category: :general) } let!(:source_tag) { create(:tag, category: :general) }
+31
ファイルの表示
@@ -897,6 +897,37 @@ RSpec.describe 'Gekanator learning API', type: :request do
end end
describe 'GET /gekanator/questions' do 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 it 'returns accepted questions only and includes example_answers for post_similarity questions' do
sign_in_as admin sign_in_as admin
+33
ファイルの表示
@@ -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
+90
ファイルの表示
@@ -517,6 +517,24 @@ RSpec.describe 'Posts API', type: :request do
expect([true, false]).to include(json['viewed']) expect([true, false]).to include(json['viewed'])
end 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 context 'when post has parent, child, and sibling posts' do
let!(:parent_post) do let!(:parent_post) do
create_parent_post!( create_parent_post!(
@@ -697,6 +715,58 @@ RSpec.describe 'Posts API', type: :request do
expect(names).not_to include('manko') expect(names).not_to include('manko')
end 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 context "when nico tag already exists in tags" do
before do before do
Tag.find_undiscard_or_create_by!( Tag.find_undiscard_or_create_by!(
@@ -930,6 +1000,26 @@ RSpec.describe 'Posts API', type: :request do
expect(names).to include('spec_tag_2') expect(names).to include('spec_tag_2')
end 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 context "when nico tag already exists in tags" do
before do before do
Tag.find_undiscard_or_create_by!( Tag.find_undiscard_or_create_by!(
+11
ファイルの表示
@@ -21,6 +21,7 @@ RSpec.describe 'TagVersions API', type: :request do
event_type:, event_type:,
name:, name:,
category:, category:,
deprecated_at: nil,
aliases: [], aliases: [],
parent_tags: [], parent_tags: [],
created_by_user:, created_by_user:,
@@ -33,6 +34,7 @@ RSpec.describe 'TagVersions API', type: :request do
event_type: event_type, event_type: event_type,
name: name, name: name,
category: category, category: category,
deprecated_at: deprecated_at,
aliases: Array(aliases).join(' '), aliases: Array(aliases).join(' '),
parent_tag_ids: Array(parent_tags).map(&:id).join(' '), parent_tag_ids: Array(parent_tags).map(&:id).join(' '),
created_by_user: created_by_user, created_by_user: created_by_user,
@@ -65,6 +67,7 @@ RSpec.describe 'TagVersions API', type: :request do
event_type: 'update', event_type: 'update',
name: 'new_tag_name', name: 'new_tag_name',
category: 'meme', category: 'meme',
deprecated_at: t_v2,
aliases: ['alias_shared', 'alias_new'], aliases: ['alias_shared', 'alias_new'],
parent_tags: [parent_shared, parent_new], parent_tags: [parent_shared, parent_new],
created_by_user: member, created_by_user: member,
@@ -133,6 +136,10 @@ RSpec.describe 'TagVersions API', type: :request do
'current' => 'meme', 'current' => 'meme',
'prev' => 'general' 'prev' => 'general'
) )
expect(latest.fetch('deprecated_at')).to eq(
'current' => t_v2.iso8601,
'prev' => nil
)
expect(latest.fetch('aliases')).to include( expect(latest.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'context' }, { 'name' => 'alias_shared', 'type' => 'context' },
{ 'name' => 'alias_new', 'type' => 'added' }, { 'name' => 'alias_new', 'type' => 'added' },
@@ -178,6 +185,10 @@ RSpec.describe 'TagVersions API', type: :request do
'current' => 'general', 'current' => 'general',
'prev' => nil 'prev' => nil
) )
expect(first.fetch('deprecated_at')).to eq(
'current' => nil,
'prev' => nil
)
expect(first.fetch('aliases')).to include( expect(first.fetch('aliases')).to include(
{ 'name' => 'alias_shared', 'type' => 'added' }, { 'name' => 'alias_shared', 'type' => 'added' },
{ 'name' => 'alias_old', 'type' => 'added' } { 'name' => 'alias_old', 'type' => 'added' }
+3
ファイルの表示
@@ -89,6 +89,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
category: 'general', category: 'general',
aliases: '', aliases: '',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
@@ -123,6 +124,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
category: 'meme', category: 'meme',
aliases: '', aliases: '',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
@@ -149,6 +151,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
category: 'general', category: 'general',
aliases: 'put_tag_alias_only_alias', aliases: 'put_tag_alias_only_alias',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
+193
ファイルの表示
@@ -76,6 +76,27 @@ RSpec.describe 'Tags API', type: :request do
expect(response_tags.first['id']).to eq(meme.id) expect(response_tags.first['id']).to eq(meme.id)
end 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 it 'filters tags by post_count range' do
low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general) low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general)
mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), 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(t['matched_alias']).to eq('unko')
expect(json.map { |x| x['name'] }).not_to include('unknown') expect(json.map { |x| x['name'] }).not_to include('unknown')
end 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 end
describe 'GET /tags/name/:name' do 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) expect(versions.second.created_by_user_id).to eq(member_user.id)
end 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 it 'returns 422 when changing normal tag category to nico' do
expect { expect {
patch "/tags/#{tag.id}", params: { category: 'nico' } 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['has_children']).to eq(true)
expect(row['children']).to eq([]) expect(row['children']).to eq([])
end 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 end
describe 'GET /tags/name/:name/materials' do describe 'GET /tags/name/:name/materials' do
@@ -732,6 +899,20 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('general') expect(tag.category).to eq('general')
end 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 it 'name, category, aliases, parent tags をまとめて更新できる' do
old_parent = Tag.create!( old_parent = Tag.create!(
tag_name: TagName.create!(name: 'put_old_parent'), tag_name: TagName.create!(name: 'put_old_parent'),
@@ -749,6 +930,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'meme', category: 'meme',
aliases: 'put_alias_a put_alias_b put_alias_a', aliases: 'put_alias_a put_alias_b put_alias_a',
parent_tags: 'put_kept_parent put_new_parent', parent_tags: 'put_kept_parent put_new_parent',
deprecated: '0',
} }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -793,6 +975,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'general', category: 'general',
aliases: 'spec_tag put_alias_self_test', aliases: 'spec_tag put_alias_self_test',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -810,6 +993,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'general', category: 'general',
aliases: 'unko', aliases: 'unko',
parent_tags: 'spec_tag', parent_tags: 'spec_tag',
deprecated: '0',
} }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -825,6 +1009,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'meta', category: 'meta',
aliases: '', aliases: '',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
}.to change(TagVersion, :count).by(2) }.to change(TagVersion, :count).by(2)
@@ -860,6 +1045,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'general', category: 'general',
aliases: 'unko', aliases: 'unko',
parent_tags: new_parent.name, parent_tags: new_parent.name,
deprecated: '0',
} }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -875,6 +1061,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'nico', category: 'nico',
aliases: '', aliases: '',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
}.not_to change(TagVersion, :count) }.not_to change(TagVersion, :count)
@@ -896,6 +1083,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'nico', category: 'nico',
aliases: '', aliases: '',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
}.not_to change(NicoTagVersion, :count) }.not_to change(NicoTagVersion, :count)
@@ -916,6 +1104,7 @@ RSpec.describe 'Tags API', type: :request do
category: old_category, category: old_category,
aliases: '', aliases: '',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
}.not_to change(TagVersion, :count) }.not_to change(TagVersion, :count)
@@ -946,6 +1135,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'meme', category: 'meme',
aliases: 'unko', aliases: 'unko',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
@@ -981,6 +1171,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'general', category: 'general',
aliases: 'unko put_stolen_alias', aliases: 'unko put_stolen_alias',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
} }
.to change { tag.reload.tag_versions.count }.by(2) .to change { tag.reload.tag_versions.count }.by(2)
@@ -1015,6 +1206,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'general', category: 'general',
aliases: 'unko', aliases: 'unko',
parent_tags: child.name, parent_tags: child.name,
deprecated: '0',
} }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
@@ -1036,6 +1228,7 @@ RSpec.describe 'Tags API', type: :request do
category: 'general', category: 'general',
aliases: 'unko', aliases: 'unko',
parent_tags: '', parent_tags: '',
deprecated: '0',
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
+17 -2
ファイルの表示
@@ -18,6 +18,13 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
end end
let!(:tag) do
Tag.create!(
tag_name: tn,
category: :general,
deprecated_at: Time.zone.local(2026, 6, 1)
)
end
describe 'GET /wiki' do describe 'GET /wiki' do
it 'returns wiki pages with title' 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[0]).to have_key('title')
expect(json.map { |p| p['title'] }).to include('spec_wiki_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
end end
@@ -48,7 +57,8 @@ RSpec.describe 'Wiki API', type: :request do
expect(json).to include( expect(json).to include(
'id' => page.id, 'id' => page.id,
'title' => 'spec_wiki_title') 'title' => 'spec_wiki_title',
'deprecated_at' => tag.deprecated_at.iso8601(3))
end end
end end
@@ -409,7 +419,11 @@ RSpec.describe 'Wiki API', type: :request do
'kind' => 'content', 'kind' => 'content',
'message' => 'r2' '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['user']).to include('id' => user.id, 'name' => user.name)
expect(top).to have_key('timestamp') expect(top).to have_key('timestamp')
@@ -479,6 +493,7 @@ RSpec.describe 'Wiki API', type: :request do
expect(json).to include( expect(json).to include(
'wiki_page_id' => page.id, 'wiki_page_id' => page.id,
'title' => 'spec_wiki_title', 'title' => 'spec_wiki_title',
'deprecated_at' => tag.deprecated_at.iso8601(3),
'older_revision_id' => rev_a.id, 'older_revision_id' => rev_a.id,
'newer_revision_id' => rev_b.id 'newer_revision_id' => rev_b.id
) )
+5 -2
ファイルの表示
@@ -4,11 +4,12 @@ require 'rails_helper'
RSpec.describe 'post_similarity:calc' do RSpec.describe 'post_similarity:calc' do
include RakeTaskHelper include RakeTaskHelper
it 'calls Similarity::Calc with Post and :tags' do it 'calculates similarities from active tags only' do
# 必要最低限のデータ # 必要最低限のデータ
t1 = Tag.create!(name: "t1") t1 = Tag.create!(name: "t1")
t2 = Tag.create!(name: "t2") t2 = Tag.create!(name: "t2")
t3 = Tag.create!(name: "t3") t3 = Tag.create!(name: "t3")
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
p1 = Post.create!(url: "https://example.com/1") p1 = Post.create!(url: "https://example.com/1")
p2 = Post.create!(url: "https://example.com/2") 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: p2, tag: t3)
PostTag.create!(post: p3, 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") } expect { run_rake_task("post_similarity:calc") }
.to change { PostSimilarity.count }.from(0) .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 = 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) ps_rev = PostSimilarity.find_by!(post_id: p2.id, target_post_id: p1.id)
expect(ps_rev.cos).to eq(ps.cos) expect(ps_rev.cos).to eq(ps.cos)
expect(ps.cos).to be_within(0.0001).of(0.5)
end end
end end
+5 -2
ファイルの表示
@@ -4,11 +4,12 @@ require 'rails_helper'
RSpec.describe 'tag_similarity:calc' do RSpec.describe 'tag_similarity:calc' do
include RakeTaskHelper include RakeTaskHelper
it 'calls Similarity::Calc with Tag and :posts' do it 'calculates similarities for active tags only' do
# 必要最低限のデータ # 必要最低限のデータ
t1 = Tag.create!(name: "t1") t1 = Tag.create!(name: "t1")
t2 = Tag.create!(name: "t2") t2 = Tag.create!(name: "t2")
t3 = Tag.create!(name: "t3") t3 = Tag.create!(name: "t3")
deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current)
p1 = Post.create!(url: "https://example.com/1") p1 = Post.create!(url: "https://example.com/1")
p2 = Post.create!(url: "https://example.com/2") 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: p2, tag: t3)
PostTag.create!(post: p3, tag: t3) PostTag.create!(post: p3, tag: t3)
PostTag.create!(post: p1, tag: deprecated_tag)
expect { run_rake_task("tag_similarity:calc") } expect { run_rake_task("tag_similarity:calc") }
.to change { TagSimilarity.count }.from(0) .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 = 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) ps_rev = TagSimilarity.find_by!(tag_id: t2.id, target_tag_id: t1.id)
expect(ps_rev.cos).to eq(ps.cos) 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
end end
+15
ファイルの表示
@@ -18,6 +18,21 @@ describe ('TagLink', () => {
expect (screen.getByText ('4')).toBeInTheDocument () expect (screen.getByText ('4')).toBeInTheDocument ()
}) })
it ('does not append deprecated state to the rendered tag name', () => {
renderWithProviders (
<TagLink
tag={buildTag ({
name: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
})}
withWiki={false}
withCount={false}/>,
)
expect (screen.getByRole ('link', { name: '旧タグ' })).toBeInTheDocument ()
expect (screen.queryByText ('(廃止)')).not.toBeInTheDocument ()
})
it ('links wiki markers to the correct detail route', () => { it ('links wiki markers to the correct detail route', () => {
renderWithProviders ( renderWithProviders (
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>, <TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
+1 -1
ファイルの表示
@@ -128,4 +128,4 @@ const TagLink: FC<Props> = ({ tag,
</>) </>)
} }
export default TagLink export default TagLink
+23 -1
ファイルの表示
@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiPost } from '@/lib/api' import { apiGet, apiPost } from '@/lib/api'
import { import {
buildGekanatorQuestions, buildGekanatorQuestions,
expectedAnswerForQuestion, expectedAnswerForQuestion,
fetchGekanatorPosts,
fetchGekanatorQuestions,
learnedSemanticSideForPost, learnedSemanticSideForPost,
questionIdForCondition, questionIdForCondition,
restoreGekanatorQuestion, restoreGekanatorQuestion,
@@ -24,6 +26,7 @@ vi.mock('@/lib/api', () => ({
})) }))
const mockedApiPost = vi.mocked(apiPost) const mockedApiPost = vi.mocked(apiPost)
const mockedApiGet = vi.mocked(apiGet)
const post = (overrides: Partial<Post> = {}): Post => ({ const post = (overrides: Partial<Post> = {}): Post => ({
id: 1, id: 1,
@@ -43,6 +46,24 @@ const post = (overrides: Partial<Post> = {}): Post => ({
...overrides, ...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', () => { describe('expectedAnswerForQuestion', () => {
it('returns a direct example answer when present', () => { it('returns a direct example answer when present', () => {
const question: StoredGekanatorQuestion = { const question: StoredGekanatorQuestion = {
@@ -126,6 +147,7 @@ describe('expectedAnswerForQuestion', () => {
postCount: 1, postCount: 1,
createdAt: '2026-06-10T00:00:00.000Z', createdAt: '2026-06-10T00:00:00.000Z',
updatedAt: '2026-06-10T00:00:00.000Z', updatedAt: '2026-06-10T00:00:00.000Z',
deprecatedAt: null,
hasWiki: false, hasWiki: false,
hasDeerjikists: false, hasDeerjikists: false,
materialId: null, materialId: null,
+8 -1
ファイルの表示
@@ -17,6 +17,10 @@ const mWiki = match<{ title: string }> ('/wiki/:title')
const mTag = match<{ id: string }> ('/tags/:id') 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 prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
const title = url.searchParams.get ('title') ?? '' const title = url.searchParams.get ('title') ?? ''
@@ -156,13 +160,16 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
const createdTo = url.searchParams.get ('created_to') ?? '' const createdTo = url.searchParams.get ('created_to') ?? ''
const updatedFrom = url.searchParams.get ('updated_from') ?? '' const updatedFrom = url.searchParams.get ('updated_from') ?? ''
const updatedTo = url.searchParams.get ('updated_to') ?? '' 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 page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20) const limit = Number (url.searchParams.get ('limit') || 20)
const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder
const keys = { const keys = {
post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, page, limit, order } updatedFrom, updatedTo, deprecated, page, limit, order }
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: tagsKeys.index (keys), queryKey: tagsKeys.index (keys),
+15
ファイルの表示
@@ -20,6 +20,7 @@ const baseParams: FetchTagsParams = {
createdTo: '', createdTo: '',
updatedFrom: '', updatedFrom: '',
updatedTo: '', updatedTo: '',
deprecated: null,
page: 1, page: 1,
limit: 30, limit: 30,
order: 'updated_at:desc', 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 () => { it ('returns null when tag fetches fail', async () => {
api.apiGet.mockRejectedValueOnce (new Error ('missing')) api.apiGet.mockRejectedValueOnce (new Error ('missing'))
api.apiGet.mockRejectedValueOnce (new Error ('missing')) api.apiGet.mockRejectedValueOnce (new Error ('missing'))
+3 -2
ファイルの表示
@@ -10,7 +10,8 @@ import type { Deerjikist,
export const fetchTags = async ( export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, page, limit, order }: FetchTagsParams, updatedFrom, updatedTo, deprecated,
page, limit, order }: FetchTagsParams,
): Promise<{ tags: Tag[] ): Promise<{ tags: Tag[]
count: number }> => count: number }> =>
await apiGet ('/tags', { params: { await apiGet ('/tags', { params: {
@@ -23,6 +24,7 @@ export const fetchTags = async (
...(createdTo && { created_to: createdTo }), ...(createdTo && { created_to: createdTo }),
...(updatedFrom && { updated_from: updatedFrom }), ...(updatedFrom && { updated_from: updatedFrom }),
...(updatedTo && { updated_to: updatedTo }), ...(updatedTo && { updated_to: updatedTo }),
...(deprecated != null && { deprecated: deprecated ? '1' : '0' }),
...(page && { page }), ...(page && { page }),
...(limit && { limit }), ...(limit && { limit }),
...(order && { order }) } }) ...(order && { order }) } })
@@ -64,7 +66,6 @@ export const fetchTagByName = async (name: string): Promise<Tag | null> => {
} }
} }
export const fetchTagChanges = async ( export const fetchTagChanges = async (
{ id, page, limit }: { { id, page, limit }: {
id?: string id?: string
+21 -1
ファイルの表示
@@ -19,7 +19,12 @@ import type { FC, FormEvent } from 'react'
import type { Category, Tag } from '@/types' import type { Category, Tag } from '@/types'
type TagFormField = 'name' | 'category' | 'aliases' | 'parentTags' type TagFormField =
| 'name'
| 'category'
| 'aliases'
| 'parentTags'
| 'deprecated'
const TagDetailPage: FC = () => { const TagDetailPage: FC = () => {
@@ -35,6 +40,7 @@ const TagDetailPage: FC = () => {
const [category, setCategory] = useState<Category> ('general') const [category, setCategory] = useState<Category> ('general')
const [aliases, setAliases] = useState ('') const [aliases, setAliases] = useState ('')
const [parentTags, setParentTags] = useState ('') const [parentTags, setParentTags] = useState ('')
const [deprecated, setDeprecated] = useState (false)
const [disabled, setDisabled] = useState (true) const [disabled, setDisabled] = useState (true)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TagFormField> () useValidationErrors<TagFormField> ()
@@ -50,6 +56,7 @@ const TagDetailPage: FC = () => {
formData.append ('category', category) formData.append ('category', category)
formData.append ('aliases', aliases) formData.append ('aliases', aliases)
formData.append ('parent_tags', parentTags) formData.append ('parent_tags', parentTags)
formData.append ('deprecated', deprecated ? '1' : '0')
try try
{ {
@@ -59,6 +66,7 @@ const TagDetailPage: FC = () => {
setCategory (data.category as Category) setCategory (data.category as Category)
setAliases (data.aliases.join (' ')) setAliases (data.aliases.join (' '))
setParentTags (data.parents.map (t => t.name).join (' ')) setParentTags (data.parents.map (t => t.name).join (' '))
setDeprecated (Boolean (data.deprecatedAt))
qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root })
@@ -82,6 +90,7 @@ const TagDetailPage: FC = () => {
setCategory (tag.category as Category) setCategory (tag.category as Category)
setAliases (tag.aliases.join (' ')) setAliases (tag.aliases.join (' '))
setParentTags (tag.parents.map (t => t.name).join (' ')) setParentTags (tag.parents.map (t => t.name).join (' '))
setDeprecated (Boolean (tag.deprecatedAt))
setDisabled (tag.category === 'nico') setDisabled (tag.category === 'nico')
}, [tag]) }, [tag])
@@ -165,6 +174,17 @@ const TagDetailPage: FC = () => {
</>)} </>)}
</FormField> </FormField>
<FormField label="廃止済" messages={fieldErrors.deprecated}>
{({ describedBy, invalid }) => (
<input
type="checkbox"
disabled={disabled}
checked={deprecated}
onChange={e => setDeprecated (e.target.checked)}
aria-describedby={describedBy}
aria-invalid={invalid}/>)}
</FormField>
<div className="py-3"> <div className="py-3">
<button <button
type="submit" type="submit"
+25 -6
ファイルの表示
@@ -20,17 +20,28 @@ import type { FC } from 'react'
const renderDiff = (diff: { current: string | null; prev: string | null }) => ( const renderDiff = (diff: { current: string | null; prev: string | null }) => (
<> <>
{(diff.prev && diff.prev !== diff.current) && ( {diff.prev !== diff.current
? (
<> <>
<del className="text-red-600 dark:text-red-400"> <del className="text-red-600 dark:text-red-400">
{diff.prev} {diff.prev && <>{diff.prev}<br/></>}
</del> </del>
{diff.current && <br/>} <ins className="text-green-600 dark:text-green-400">
</>)} {diff.current}
{diff.current} </ins>
</>)
: diff.current}
</>) </>)
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const renderStateDiff = (diff: { current: string | null; prev: string | null }) =>
renderDiff ({ current: tagStateLabel (diff.current),
prev: tagStateLabel (diff.prev) })
const TagHistoryPage: FC = () => { const TagHistoryPage: FC = () => {
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
@@ -72,6 +83,8 @@ const TagHistoryPage: FC = () => {
<col className="w-96"/> <col className="w-96"/>
{/* カテゴリ */} {/* カテゴリ */}
<col className="w-96"/> <col className="w-96"/>
{/* 状態 */}
<col className="w-32"/>
{/* 別名 */} {/* 別名 */}
<col className="w-[48rem]"/> <col className="w-[48rem]"/>
{/* 上位タグ */} {/* 上位タグ */}
@@ -87,6 +100,7 @@ const TagHistoryPage: FC = () => {
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
<th className="p-2 text-left"></th> <th className="p-2 text-left"></th>
@@ -106,6 +120,9 @@ const TagHistoryPage: FC = () => {
prev: (change.category.prev prev: (change.category.prev
&& CATEGORY_NAMES[change.category.prev]) })} && CATEGORY_NAMES[change.category.prev]) })}
</td> </td>
<td className="p-2 break-all">
{renderStateDiff (change.deprecatedAt)}
</td>
<td className="p-2"> <td className="p-2">
{change.aliases.map ((tag, i) => ( {change.aliases.map ((tag, i) => (
tag.type === 'added' tag.type === 'added'
@@ -178,6 +195,7 @@ const TagHistoryPage: FC = () => {
`/tags/${ change.tagId }`, `/tags/${ change.tagId }`,
{ name: change.name.current, { name: change.name.current,
category: change.category.current, category: change.category.current,
deprecated: change.deprecatedAt.current ? '1' : '0',
aliases: aliases:
change.aliases change.aliases
.filter (t => t.type !== 'removed') .filter (t => t.type !== 'removed')
@@ -211,4 +229,5 @@ const TagHistoryPage: FC = () => {
</MainArea>) </MainArea>)
} }
export default TagHistoryPage
export default TagHistoryPage
+17 -3
ファイルの表示
@@ -14,13 +14,22 @@ vi.mock ('@/lib/tags', () => tagsApi)
describe ('TagListPage', () => { describe ('TagListPage', () => {
it ('loads tags from URL filters and renders the results table', async () => { it ('loads tags from URL filters and renders the results table', async () => {
tagsApi.fetchTags.mockResolvedValueOnce ({ tagsApi.fetchTags.mockResolvedValueOnce ({
tags: [buildTag ({ id: 7, name: '虹夏', category: 'character', postCount: 99 })], tags: [buildTag ({
id: 7,
name: '虹夏',
category: 'character',
postCount: 99,
deprecatedAt: '2026-06-01T00:00:00.000Z',
})],
count: 1, count: 1,
}) })
renderWithProviders ( renderWithProviders (
<TagListPage/>, <TagListPage/>,
{ route: '/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5' }, {
route:
'/tags?name=%E8%99%B9&category=character&page=3&post_count_gte=5&deprecated=1',
},
) )
await waitFor (() => { await waitFor (() => {
@@ -30,6 +39,7 @@ describe ('TagListPage', () => {
category: 'character', category: 'character',
page: 3, page: 3,
postCountGTE: 5, postCountGTE: 5,
deprecated: true,
}), }),
) )
}) })
@@ -38,6 +48,8 @@ describe ('TagListPage', () => {
'/tags/7', '/tags/7',
) )
expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0) expect (screen.getAllByText ('キャラクター').length).toBeGreaterThan (0)
expect (screen.getAllByRole ('combobox')[1]).toHaveValue ('1')
expect (screen.getAllByText ('廃止')).toHaveLength (2)
}) })
it ('navigates to a normalized search URL on submit', async () => { it ('navigates to a normalized search URL on submit', async () => {
@@ -46,7 +58,9 @@ describe ('TagListPage', () => {
renderWithProviders (<TagListPage/>, { route: '/tags' }) renderWithProviders (<TagListPage/>, { route: '/tags' })
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } }) fireEvent.change (screen.getByRole ('textbox'), { target: { value: '虹夏' } })
fireEvent.change (screen.getByRole ('combobox'), { target: { value: 'character' } }) fireEvent.change (screen.getAllByRole ('combobox')[0], {
target: { value: 'character' },
})
fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!) fireEvent.submit (screen.getByRole ('button', { name: '検索' }).closest ('form')!)
await waitFor (() => { await waitFor (() => {
+35 -2
ファイルの表示
@@ -29,6 +29,13 @@ const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
} }
const boolFromQuery = (value: string | null): boolean =>
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
const tagStateLabel = (deprecatedAt: string | null) => deprecatedAt ? '廃止' : ''
const TagListPage: FC = () => { const TagListPage: FC = () => {
const location = useLocation () const location = useLocation ()
@@ -48,6 +55,9 @@ const TagListPage: FC = () => {
const qCreatedTo = query.get ('created_to') ?? '' const qCreatedTo = query.get ('created_to') ?? ''
const qUpdatedFrom = query.get ('updated_from') ?? '' const qUpdatedFrom = query.get ('updated_from') ?? ''
const qUpdatedTo = query.get ('updated_to') ?? '' const qUpdatedTo = query.get ('updated_to') ?? ''
const qDeprecated = query.has ('deprecated')
? boolFromQuery (query.get ('deprecated'))
: null
const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder
const [name, setName] = useState ('') const [name, setName] = useState ('')
@@ -58,6 +68,7 @@ const TagListPage: FC = () => {
const [createdTo, setCreatedTo] = useState<string | null> (null) const [createdTo, setCreatedTo] = useState<string | null> (null)
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null) const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
const [updatedTo, setUpdatedTo] = useState<string | null> (null) const [updatedTo, setUpdatedTo] = useState<string | null> (null)
const [deprecated, setDeprecated] = useState<boolean | null> (null)
const keys = { const keys = {
page, limit, order, page, limit, order,
@@ -69,7 +80,8 @@ const TagListPage: FC = () => {
createdFrom: qCreatedFrom, createdFrom: qCreatedFrom,
createdTo: qCreatedTo, createdTo: qCreatedTo,
updatedFrom: qUpdatedFrom, updatedFrom: qUpdatedFrom,
updatedTo: qUpdatedTo } updatedTo: qUpdatedTo,
deprecated: qDeprecated }
const { data, isLoading: loading } = useQuery ({ const { data, isLoading: loading } = useQuery ({
queryKey: tagsKeys.index (keys), queryKey: tagsKeys.index (keys),
queryFn: () => fetchTags (keys) }) queryFn: () => fetchTags (keys) })
@@ -85,10 +97,11 @@ const TagListPage: FC = () => {
setCreatedTo (qCreatedTo) setCreatedTo (qCreatedTo)
setUpdatedFrom (qUpdatedFrom) setUpdatedFrom (qUpdatedFrom)
setUpdatedTo (qUpdatedTo) setUpdatedTo (qUpdatedTo)
setDeprecated (qDeprecated)
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE, }, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
qPostCountLTE, qUpdatedFrom, qUpdatedTo]) qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated])
const handleSearch = (e: FormEvent) => { const handleSearch = (e: FormEvent) => {
e.preventDefault () e.preventDefault ()
@@ -104,6 +117,8 @@ const TagListPage: FC = () => {
setIf (qs, 'created_to', createdTo) setIf (qs, 'created_to', createdTo)
setIf (qs, 'updated_from', updatedFrom) setIf (qs, 'updated_from', updatedFrom)
setIf (qs, 'updated_to', updatedTo) setIf (qs, 'updated_to', updatedTo)
if (deprecated != null)
qs.set ('deprecated', deprecated ? '1' : '0')
qs.set ('page', '1') qs.set ('page', '1')
qs.set ('order', order) qs.set ('order', order)
@@ -201,6 +216,21 @@ const TagListPage: FC = () => {
</>)} </>)}
</FormField> </FormField>
<FormField label="状態">
{({ invalid }) => (
<select
value={deprecated == null ? '' : (deprecated ? '1' : '0')}
onChange={e => setDeprecated (
e.target.value === ''
? null
: e.target.value === '1')}
className={inputClass (invalid)}>
<option value="">&nbsp;</option>
<option value="0"></option>
<option value="1"></option>
</select>)}
</FormField>
<div className="py-3"> <div className="py-3">
<button <button
type="submit" type="submit"
@@ -219,6 +249,7 @@ const TagListPage: FC = () => {
<col className="w-72"/> <col className="w-72"/>
<col className="w-16"/> <col className="w-16"/>
<col className="w-48"/> <col className="w-48"/>
<col className="w-32"/>
<col className="w-72"/> <col className="w-72"/>
<col className="w-48"/> <col className="w-48"/>
<col className="w-56"/> <col className="w-56"/>
@@ -249,6 +280,7 @@ const TagListPage: FC = () => {
currentOrder={order} currentOrder={order}
defaultDirection={defaultDirection}/> defaultDirection={defaultDirection}/>
</th> </th>
<th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap"></th> <th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap"></th> <th className="p-2 text-left whitespace-nowrap"></th>
<th className="p-2 text-left whitespace-nowrap"> <th className="p-2 text-left whitespace-nowrap">
@@ -280,6 +312,7 @@ const TagListPage: FC = () => {
</td> </td>
<td className="p-2 text-right">{row.postCount}</td> <td className="p-2 text-right">{row.postCount}</td>
<td className="p-2">{CATEGORY_NAMES[row.category]}</td> <td className="p-2">{CATEGORY_NAMES[row.category]}</td>
<td className="p-2">{tagStateLabel (row.deprecatedAt)}</td>
<td className="p-2">{row.aliases.join (' ')}</td> <td className="p-2">{row.aliases.join (' ')}</td>
<td className="p-2"> <td className="p-2">
{row.parents.map (t => ( {row.parents.map (t => (
+54
ファイルの表示
@@ -0,0 +1,54 @@
import { screen } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildTag, buildWikiPage } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (),
}))
const tagsApi = vi.hoisted (() => ({
fetchTagByName: vi.fn (),
}))
const postsApi = vi.hoisted (() => ({
fetchPosts: vi.fn (),
}))
vi.mock ('@/lib/wiki', () => wikiApi)
vi.mock ('@/lib/tags', () => tagsApi)
vi.mock ('@/lib/posts', () => postsApi)
describe ('WikiDetailPage', () => {
it ('renders deprecated state outside the wiki title link', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce (buildWikiPage ({
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}))
tagsApi.fetchTagByName.mockResolvedValueOnce (buildTag ({
name: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}))
postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 })
renderWithProviders (
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
</Routes>,
{ route: '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0' },
)
const marker = await screen.findByText ('(廃止)')
const heading = marker.closest ('h1')
const link = screen.getByRole ('link', { name: '旧タグ' })
expect (heading).not.toBeNull ()
expect (heading!).toHaveTextContent ('旧タグ(廃止)')
expect (link).toBeInTheDocument ()
expect (marker.closest ('a')).toBeNull ()
})
})
+6 -2
ファイルの表示
@@ -39,6 +39,7 @@ const WikiDetailPage: FC = () => {
queryFn: () => fetchWikiPageByTitle (title, { version }) }) queryFn: () => fetchWikiPageByTitle (title, { version }) })
const effectiveTitle = wikiPage?.title ?? title const effectiveTitle = wikiPage?.title ?? title
const deprecated = wikiPage?.deprecatedAt != null
const { data: tag } = useQuery ({ const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle), enabled: Boolean (effectiveTitle),
@@ -88,7 +89,7 @@ const WikiDetailPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`${ title } Wiki | ${ SITE_TITLE }`}</title> <title>{`${ effectiveTitle }${ deprecated ? '(廃止)' : '' } Wiki | ${ SITE_TITLE }`}</title>
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>} {!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
</Helmet> </Helmet>
@@ -110,10 +111,13 @@ const WikiDetailPage: FC = () => {
<article className="prose dark:prose-invert mx-auto p-4"> <article className="prose dark:prose-invert mx-auto p-4">
<h1 className="prose-a:no-underline"> <h1 className="prose-a:no-underline">
<TagLink tag={tag ?? defaultTag} <TagLink tag={tag ?? { ...defaultTag,
name: effectiveTitle,
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
withWiki={false} withWiki={false}
withCount={false} withCount={false}
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{deprecated && <span></span>}
</h1> </h1>
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} {loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
+23
ファイルの表示
@@ -16,6 +16,7 @@ describe ('WikiDiffPage', () => {
api.apiGet.mockResolvedValueOnce ({ api.apiGet.mockResolvedValueOnce ({
wikiPageId: 3, wikiPageId: 3,
title: '差分対象', title: '差分対象',
deprecatedAt: null,
olderRevisionId: 1, olderRevisionId: 1,
newerRevisionId: 2, newerRevisionId: 2,
diff: [ diff: [
@@ -43,4 +44,26 @@ describe ('WikiDiffPage', () => {
expect (screen.getByText ('added line')).toBeInTheDocument () expect (screen.getByText ('added line')).toBeInTheDocument ()
expect (screen.getByText ('removed line')).toBeInTheDocument () expect (screen.getByText ('removed line')).toBeInTheDocument ()
}) })
it ('appends deprecated state to the wiki title', async () => {
api.apiGet.mockResolvedValueOnce ({
wikiPageId: 3,
title: '廃止 Wiki',
deprecatedAt: '2026-06-01T00:00:00.000Z',
olderRevisionId: 1,
newerRevisionId: 2,
diff: [],
})
renderWithProviders (
<Routes>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
</Routes>,
{ route: '/wiki/3/diff?from=1&to=2' },
)
expect (await screen.findByRole ('heading', {
name: '廃止 Wiki(廃止)',
})).toBeInTheDocument ()
})
}) })
+5 -2
ファイルの表示
@@ -23,6 +23,9 @@ const WikiDiffPage: FC = () => {
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
const from = query.get ('from') const from = query.get ('from')
const to = query.get ('to') const to = query.get ('to')
const displayTitle = diff
? `${ diff.title }${ diff.deprecatedAt != null ? '(廃止)' : '' }`
: ''
useEffect (() => { useEffect (() => {
void (async () => { void (async () => {
@@ -33,9 +36,9 @@ const WikiDiffPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`Wiki 差分: ${ diff?.title } | ${ SITE_TITLE }`}</title> <title>{`Wiki 差分: ${ displayTitle } | ${ SITE_TITLE }`}</title>
</Helmet> </Helmet>
<PageTitle>{diff?.title}</PageTitle> <PageTitle>{displayTitle}</PageTitle>
<div className="prose mx-auto p-4"> <div className="prose mx-auto p-4">
{diff {diff
? ( ? (
+38
ファイルの表示
@@ -0,0 +1,38 @@
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('WikiHistoryPage', () => {
it ('renders deprecated state outside the wiki title link', async () => {
api.apiGet.mockResolvedValueOnce ([{
revisionId: 2,
pred: 1,
succ: null,
wikiPage: {
id: 3,
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
},
user: { id: 4, name: 'tester' },
kind: 'content',
message: 'updated',
timestamp: '2026-06-02T00:00:00.000Z',
}])
renderWithProviders (<WikiHistoryPage/>)
const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toHaveAttribute ('href', '/wiki/%E6%97%A7%E3%82%BF%E3%82%B0?version=2')
expect (marker.closest ('a')).toBeNull ()
})
})
+1
ファイルの表示
@@ -59,6 +59,7 @@ const WikiHistoryPage: FC = () => {
to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}> to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}>
{change.wikiPage.title} {change.wikiPage.title}
</PrefetchLink> </PrefetchLink>
{change.wikiPage.deprecatedAt != null && <span></span>}
</td> </td>
<td className="p-2"> <td className="p-2">
{change.pred == null ? '新規' : '更新'} {change.pred == null ? '新規' : '更新'}
+17
ファイルの表示
@@ -42,4 +42,21 @@ describe ('WikiSearchPage', () => {
}) })
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument () expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
}) })
it ('marks deprecated wiki tags in the result title', async () => {
api.apiGet.mockResolvedValueOnce ([
buildWikiPage ({
title: '旧タグ',
deprecatedAt: '2026-06-01T00:00:00.000Z',
}),
])
renderWithProviders (<WikiSearchPage/>)
const link = await screen.findByRole ('link', { name: '旧タグ' })
const marker = screen.getByText ('(廃止)')
expect (link).toBeInTheDocument ()
expect (marker.closest ('a')).toBeNull ()
})
}) })
+1
ファイルの表示
@@ -86,6 +86,7 @@ const WikiSearchPage: FC = () => {
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}> <PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title} {page.title}
</PrefetchLink> </PrefetchLink>
{page.deprecatedAt != null && <span></span>}
</td> </td>
<td className="p-2"> <td className="p-2">
{dateString (page.updatedAt)} {dateString (page.updatedAt)}
+2
ファイルの表示
@@ -13,6 +13,7 @@ export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
id: 1, id: 1,
name: 'テストタグ', name: 'テストタグ',
category: 'general', category: 'general',
deprecatedAt: null,
aliases: [], aliases: [],
parents: [], parents: [],
postCount: 12, postCount: 12,
@@ -57,6 +58,7 @@ export const buildUser = (overrides: Partial<User> = {}): User => ({
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({ export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
id: 1, id: 1,
title: 'テストWiki', title: 'テストWiki',
deprecatedAt: null,
createdUserId: 1, createdUserId: 1,
updatedUserId: 1, updatedUserId: 1,
createdAt: '2026-01-02T03:04:05.000Z', createdAt: '2026-01-02T03:04:05.000Z',
+18 -13
ファイルの表示
@@ -39,18 +39,19 @@ export type FetchTagsOrderField =
| 'updated_at' | 'updated_at'
export type FetchTagsParams = { export type FetchTagsParams = {
post: number | null post: number | null
name: string name: string
category: Category | null category: Category | null
postCountGTE: number postCountGTE: number
postCountLTE: number | null postCountLTE: number | null
createdFrom: string createdFrom: string
createdTo: string createdTo: string
updatedFrom: string updatedFrom: string
updatedTo: string updatedTo: string
page: number deprecated: boolean | null
limit: number page: number
order: FetchTagsOrder } limit: number
order: FetchTagsOrder }
export type FetchNicoTagsParams = { export type FetchNicoTagsParams = {
name: string name: string
@@ -196,6 +197,7 @@ export type Tag = {
id: number id: number
name: string name: string
category: Category category: Category
deprecatedAt: string | null
aliases: string[] aliases: string[]
parents: Tag[] parents: Tag[]
postCount: number postCount: number
@@ -213,6 +215,7 @@ export type TagVersion = {
eventType: 'create' | 'update' | 'discard' | 'restore' eventType: 'create' | 'update' | 'discard' | 'restore'
name: { current: string; prev: string | null } name: { current: string; prev: string | null }
category: { current: Category; prev: Category | null } category: { current: Category; prev: Category | null }
deprecatedAt: { current: string | null; prev: string | null }
aliases: { name: string; type: 'context' | 'added' | 'removed' }[] aliases: { name: string; type: 'context' | 'added' | 'removed' }[]
parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[] parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[]
createdAt: string createdAt: string
@@ -296,6 +299,7 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha
export type WikiPage = { export type WikiPage = {
id: number id: number
title: string title: string
deprecatedAt: string | null
createdUserId: number createdUserId: number
updatedUserId: number updatedUserId: number
createdAt: string createdAt: string
@@ -309,7 +313,7 @@ export type WikiPageChange = {
revisionId: number revisionId: number
pred: number | null pred: number | null
succ: null succ: null
wikiPage: Pick<WikiPage, 'id' | 'title'> wikiPage: Pick<WikiPage, 'id' | 'title' | 'deprecatedAt'>
user: Pick<User, 'id' | 'name'> user: Pick<User, 'id' | 'name'>
kind: 'content' | 'redirect' kind: 'content' | 'redirect'
message: string | null message: string | null
@@ -318,6 +322,7 @@ export type WikiPageChange = {
export type WikiPageDiff = { export type WikiPageDiff = {
wikiPageId: number wikiPageId: number
title: string title: string
deprecatedAt: string | null
olderRevisionId: number | null olderRevisionId: number | null
newerRevisionId: number newerRevisionId: number
diff: WikiPageDiffDiff[] } diff: WikiPageDiffDiff[] }
+2 -1
ファイルの表示
@@ -27,5 +27,6 @@
"@/*": ["*"] "@/*": ["*"]
} }
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
} }