コミットを比較
3 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 2a40dd999b | |||
| 2cd9c60368 | |||
| 8eb8fb355b |
@@ -107,11 +107,16 @@ npm run preview
|
||||
- Prefer single quotes for strings unless interpolation or escaping makes
|
||||
double quotes better.
|
||||
- Ruby: never put a space before method-call parentheses.
|
||||
- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。
|
||||
- Ruby: never put a line break immediately before `)`.
|
||||
- Ruby: do not use `%w` or `%i`.
|
||||
- In Ruby, when an `if` condition is split across multiple lines and combines
|
||||
clauses with `&&` or `||`, wrap the whole condition in parentheses.
|
||||
- Ruby hashes are not blocks; keep `}` on the same line as the final pair.
|
||||
- Ruby hashes keep the first pair on the same line as `{` unless line length
|
||||
requires a break.
|
||||
- Short Ruby hashes may stay visually compact across two lines with the first
|
||||
pair kept on the opening line and aligned continuation pairs below it.
|
||||
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
|
||||
indentation.
|
||||
- For arrays, never put whitespace or a line break immediately before `]`.
|
||||
|
||||
@@ -72,17 +72,23 @@ service, representation, and spec.
|
||||
- Prefer precise, minimal changes.
|
||||
- Use single quotes unless interpolation or escaping makes double quotes better.
|
||||
- Do not put a space before Ruby method-call parentheses.
|
||||
- For `render`-family method calls, omit parentheses even when passing
|
||||
keyword arguments.
|
||||
- Never put a line break immediately before `)` in Ruby.
|
||||
- Do not use `%w` or `%i` in new Ruby code.
|
||||
- Never write a Ruby line longer than 99 characters.
|
||||
- Aim to keep Ruby lines within 79 characters where practical.
|
||||
- For small Ruby method definitions that take keyword arguments, match the
|
||||
local no-parentheses style when nearby code uses it.
|
||||
- When an `if` condition is split across multiple lines and combines clauses
|
||||
with `&&` or `||`, wrap the whole condition in parentheses.
|
||||
- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
|
||||
rules.
|
||||
- Do not format Ruby hashes like Ruby blocks.
|
||||
- For Ruby hashes, keep the closing `}` on the same line as the final pair.
|
||||
- Keep the first pair on the same line as `{` by default.
|
||||
- Short Ruby hashes may stay visually compact across two lines with the first
|
||||
pair kept on the opening line and aligned continuation pairs below it.
|
||||
- If the hash would exceed the line limit, break after `{` and indent pairs
|
||||
by 4 spaces.
|
||||
- Put one logical pair per line when the expression would otherwise become
|
||||
|
||||
@@ -148,10 +148,10 @@ class PostsController < ApplicationController
|
||||
ApplicationRecord.transaction do
|
||||
post.save!
|
||||
|
||||
tags = Tag.normalise_tags!(tag_names)
|
||||
tags = Tag.normalise_tags!(tag_names, deny_deprecated: true)
|
||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
|
||||
|
||||
tags = Tag.expand_parent_tags(tags)
|
||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||
sync_post_tags!(post, tags)
|
||||
|
||||
sync_parent_posts!(post, parent_post_ids)
|
||||
@@ -165,6 +165,8 @@ class PostsController < ApplicationController
|
||||
render json: PostRepr.base(post), status: :created
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
||||
rescue Tag::DeprecatedTagNormalisationError
|
||||
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
||||
rescue ArgumentError => e
|
||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@@ -255,6 +257,8 @@ class PostsController < ApplicationController
|
||||
render json:, status: :ok
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
render_validation_error fields: { tags: ['ニコニコ・タグは直接指定できません.'] }
|
||||
rescue Tag::DeprecatedTagNormalisationError
|
||||
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
||||
rescue ArgumentError => e
|
||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@@ -378,7 +382,7 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def build_tag_tree_for tags
|
||||
tags = tags.to_a
|
||||
tags = tags.reject(&:deprecated?).to_a
|
||||
tag_ids = tags.map(&:id)
|
||||
|
||||
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
|
||||
@@ -501,7 +505,8 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def editable_tag_names_from_post post
|
||||
post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||
post.tags.not_nico.where(deprecated_at: nil)
|
||||
.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||
end
|
||||
|
||||
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
|
||||
@@ -533,9 +538,10 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def incoming_tag_names_for_snapshot raw_tag_names
|
||||
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
|
||||
tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false,
|
||||
deny_deprecated: true)
|
||||
|
||||
Tag.expand_parent_tags(tags).map(&:name).uniq.sort
|
||||
Tag.expand_parent_tags(tags).reject(&:deprecated?).map(&:name).uniq.sort
|
||||
end
|
||||
|
||||
def post_conflict_json post:, base_version_no:, base_snapshot:,
|
||||
@@ -622,13 +628,14 @@ class PostsController < ApplicationController
|
||||
original_created_from: snapshot[:original_created_from],
|
||||
original_created_before: snapshot[:original_created_before])
|
||||
|
||||
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
|
||||
editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
|
||||
deny_deprecated: true)
|
||||
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
|
||||
|
||||
readonly_tags = post.tags.nico.to_a
|
||||
|
||||
tags = readonly_tags + editable_tags
|
||||
tags = Tag.expand_parent_tags(tags)
|
||||
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
|
||||
|
||||
sync_post_tags!(post, tags)
|
||||
sync_parent_posts!(post, snapshot[:parent_post_ids])
|
||||
|
||||
@@ -17,6 +17,7 @@ class TagVersionsController < ApplicationController
|
||||
AND prev.version_no = tag_versions.version_no - 1
|
||||
SQL
|
||||
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
|
||||
'prev.deprecated_at AS prev_deprecated_at',
|
||||
'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids')
|
||||
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
|
||||
|
||||
@@ -62,6 +63,8 @@ class TagVersionsController < ApplicationController
|
||||
event_type: row.event_type,
|
||||
name: { current: row.name, prev: row.attributes['prev_name'] },
|
||||
category: { current: row.category, prev: row.attributes['prev_category'] },
|
||||
deprecated_at: { current: row.deprecated_at&.iso8601,
|
||||
prev: row.attributes['prev_deprecated_at']&.iso8601 },
|
||||
aliases: build_version_values(cur_aliases, prev_aliases, key: :name),
|
||||
parent_tags:,
|
||||
created_at: row.created_at.iso8601,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
require 'net/http'
|
||||
require 'uri'
|
||||
require 'set'
|
||||
|
||||
|
||||
class TagsController < ApplicationController
|
||||
@@ -14,6 +15,8 @@ class TagsController < ApplicationController
|
||||
post_count_between[1] = nil if post_count_between[1] < 0
|
||||
created_between = params[:created_from].presence, params[:created_to].presence
|
||||
updated_between = params[:updated_from].presence, params[:updated_to].presence
|
||||
deprecated_given = params.key?(:deprecated)
|
||||
deprecated = bool?(:deprecated)
|
||||
|
||||
order = params[:order].to_s.split(':', 2).map(&:strip)
|
||||
unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at'])
|
||||
@@ -48,6 +51,9 @@ class TagsController < ApplicationController
|
||||
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
|
||||
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
|
||||
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
|
||||
if deprecated_given
|
||||
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
|
||||
end
|
||||
|
||||
sort_sql =
|
||||
case order[0]
|
||||
@@ -77,37 +83,27 @@ class TagsController < ApplicationController
|
||||
parent_tag_id = params[:parent].to_i
|
||||
parent_tag_id = nil if parent_tag_id <= 0
|
||||
|
||||
graph = build_with_depth_graph
|
||||
|
||||
tag_ids =
|
||||
if parent_tag_id
|
||||
TagImplication.where(parent_tag_id:).select(:tag_id)
|
||||
visible_child_tag_ids(parent_tag_id, graph)
|
||||
else
|
||||
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
|
||||
visible_root_tag_ids(graph)
|
||||
end
|
||||
|
||||
tags =
|
||||
Tag
|
||||
.joins(:tag_name)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.where(category: [:meme, :character, :material])
|
||||
.where(id: tag_ids)
|
||||
.order('tag_names.name')
|
||||
.distinct
|
||||
.to_a
|
||||
|
||||
has_children_tag_ids =
|
||||
if tags.empty?
|
||||
[]
|
||||
else
|
||||
TagImplication
|
||||
.joins(:tag)
|
||||
.where(parent_tag_id: tags.map(&:id),
|
||||
tags: { category: [:meme, :character, :material] })
|
||||
.distinct
|
||||
.pluck(:parent_tag_id)
|
||||
end
|
||||
|
||||
render json: tags.map { |tag|
|
||||
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
|
||||
TagRepr.base(tag).merge(has_children: visible_child_tag_ids(tag.id, graph).present?,
|
||||
children: [])
|
||||
}
|
||||
end
|
||||
|
||||
@@ -133,6 +129,7 @@ class TagsController < ApplicationController
|
||||
|
||||
base = Tag.joins(:tag_name)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.where(deprecated_at: nil)
|
||||
base = base.where('tags.post_count > 0') if present_only
|
||||
|
||||
canonical_hit =
|
||||
@@ -252,18 +249,24 @@ class TagsController < ApplicationController
|
||||
category = params[:category].to_s.strip
|
||||
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank?
|
||||
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
||||
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
|
||||
|
||||
if name != tag.name &&
|
||||
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
|
||||
return render_unprocessable_entity('システム・タグの名称は変更できません.', field: :name)
|
||||
end
|
||||
|
||||
if tag.nico? || category == 'nico'
|
||||
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
|
||||
if (name != tag.name &&
|
||||
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]))
|
||||
return render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
|
||||
end
|
||||
|
||||
alias_names = params[:aliases].to_s.split.uniq
|
||||
parent_names = params[:parent_tags].to_s.split.uniq
|
||||
deprecated = bool?(:deprecated)
|
||||
|
||||
if tag.nico? && deprecated
|
||||
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
||||
end
|
||||
|
||||
if tag.nico? || category == 'nico'
|
||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||
end
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||
@@ -272,7 +275,11 @@ class TagsController < ApplicationController
|
||||
name_changed = name != old_name
|
||||
wiki_page = tag.tag_name.wiki_page if name_changed
|
||||
|
||||
tag.update!(category:)
|
||||
if tag.deprecated? == deprecated
|
||||
tag.update!(category:)
|
||||
else
|
||||
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
|
||||
end
|
||||
tag.tag_name.update!(name:)
|
||||
|
||||
alias_names << old_name if name_changed
|
||||
@@ -300,11 +307,17 @@ class TagsController < ApplicationController
|
||||
|
||||
name = params[:name].presence
|
||||
category = params[:category].presence
|
||||
deprecated_given = params.key?(:deprecated)
|
||||
deprecated = bool?(:deprecated)
|
||||
|
||||
tag = Tag.find(params[:id])
|
||||
|
||||
if tag.nico? && deprecated_given && deprecated
|
||||
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
||||
end
|
||||
|
||||
if tag.nico? || (category.present? && category == 'nico')
|
||||
return render_unprocessable_entity('ニコタグは変更できません.', field: :category)
|
||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||
end
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@@ -316,6 +329,9 @@ class TagsController < ApplicationController
|
||||
|
||||
tag.tag_name.update!(name:) if name.present?
|
||||
tag.update!(category:) if category.present?
|
||||
if deprecated_given && tag.deprecated? != deprecated
|
||||
tag.update!(deprecated_at: deprecated ? Time.current : nil)
|
||||
end
|
||||
|
||||
tag.reload
|
||||
|
||||
@@ -332,6 +348,93 @@ class TagsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def build_with_depth_graph
|
||||
children_by_parent_id = Hash.new { |h, k| h[k] = [] }
|
||||
parent_ids_by_child_id = Hash.new { |h, k| h[k] = [] }
|
||||
|
||||
TagImplication.pluck(:parent_tag_id, :tag_id).each do |parent_id, child_id|
|
||||
children_by_parent_id[parent_id] << child_id
|
||||
parent_ids_by_child_id[child_id] << parent_id
|
||||
end
|
||||
|
||||
tag_ids = (children_by_parent_id.keys +
|
||||
parent_ids_by_child_id.keys +
|
||||
Tag.where(category: ['meme', 'character', 'material']).pluck(:id)).uniq
|
||||
|
||||
tags_by_id = Tag.where(id: tag_ids)
|
||||
.pluck(:id, :category, :deprecated_at)
|
||||
.each_with_object({ }) do |(id, category, deprecated_at), h|
|
||||
h[id] = { category:, deprecated: deprecated_at.present? }
|
||||
end
|
||||
|
||||
{ children_by_parent_id:, parent_ids_by_child_id:, tags_by_id:,
|
||||
visible_child_tag_ids_by_parent_id: { } }
|
||||
end
|
||||
|
||||
def visible_root_tag_ids graph
|
||||
graph[:tags_by_id].filter_map do |tag_id, attrs|
|
||||
next unless with_depth_visible_tag?(attrs)
|
||||
next unless visible_root_tag?(tag_id, graph)
|
||||
|
||||
tag_id
|
||||
end
|
||||
end
|
||||
|
||||
def visible_root_tag? tag_id, graph
|
||||
seen = Set.new([tag_id])
|
||||
stack = graph[:parent_ids_by_child_id][tag_id].dup
|
||||
|
||||
until stack.empty?
|
||||
parent_id = stack.pop
|
||||
next if seen.include?(parent_id)
|
||||
|
||||
seen << parent_id
|
||||
|
||||
parent = graph[:tags_by_id][parent_id]
|
||||
next unless parent
|
||||
|
||||
return false unless parent[:deprecated]
|
||||
|
||||
stack.concat(graph[:parent_ids_by_child_id][parent_id])
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def visible_child_tag_ids parent_tag_id, graph
|
||||
cache = graph[:visible_child_tag_ids_by_parent_id]
|
||||
return cache[parent_tag_id] if cache.key?(parent_tag_id)
|
||||
|
||||
visible_ids = Set.new
|
||||
|
||||
graph[:children_by_parent_id][parent_tag_id].each do |child_tag_id|
|
||||
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, Set.new([parent_tag_id]))
|
||||
end
|
||||
|
||||
cache[parent_tag_id] = visible_ids.to_a
|
||||
end
|
||||
|
||||
def collect_visible_child_tag_ids tag_id, graph, visible_ids, seen
|
||||
return if seen.include?(tag_id)
|
||||
|
||||
seen = seen.dup << tag_id
|
||||
tag = graph[:tags_by_id][tag_id]
|
||||
return unless tag
|
||||
|
||||
if tag[:deprecated]
|
||||
graph[:children_by_parent_id][tag_id].each do |child_tag_id|
|
||||
collect_visible_child_tag_ids(child_tag_id, graph, visible_ids, seen)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
visible_ids << tag_id if with_depth_visible_tag?(tag)
|
||||
end
|
||||
|
||||
def with_depth_visible_tag? tag
|
||||
tag[:category].in?(['meme', 'character', 'material']) && !tag[:deprecated]
|
||||
end
|
||||
|
||||
def build_tag_children tag
|
||||
material = tag.materials.first
|
||||
file = nil
|
||||
|
||||
+24
-1
@@ -8,6 +8,15 @@ class Tag < ApplicationRecord
|
||||
;
|
||||
end
|
||||
|
||||
class DeprecatedTagNormalisationError < ArgumentError
|
||||
attr_reader :tag_names
|
||||
|
||||
def initialize tag_names
|
||||
@tag_names = Array(tag_names)
|
||||
super('deprecated tags are not allowed')
|
||||
end
|
||||
end
|
||||
|
||||
has_many :post_tags, inverse_of: :tag
|
||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
|
||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||
@@ -58,6 +67,7 @@ class Tag < ApplicationRecord
|
||||
validate :nico_tag_name_must_start_with_nico
|
||||
validate :tag_name_must_be_canonical
|
||||
validate :category_must_be_deerjikist_with_deerjikists
|
||||
validate :nico_tags_cannot_be_deprecated
|
||||
|
||||
scope :nico_tags, -> { nico }
|
||||
|
||||
@@ -77,6 +87,8 @@ class Tag < ApplicationRecord
|
||||
(self.tag_name ||= build_tag_name).name = val
|
||||
end
|
||||
|
||||
def deprecated? = deprecated_at?
|
||||
|
||||
def has_wiki = wiki_page.present?
|
||||
|
||||
def material_id = materials.first&.id
|
||||
@@ -92,7 +104,8 @@ class Tag < ApplicationRecord
|
||||
|
||||
def self.normalise_tags! tag_names, with_tagme: true,
|
||||
with_no_deerjikist: true,
|
||||
deny_nico: true
|
||||
deny_nico: true,
|
||||
deny_deprecated: false
|
||||
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
||||
raise NicoTagNormalisationError
|
||||
end
|
||||
@@ -101,6 +114,10 @@ class Tag < ApplicationRecord
|
||||
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
|
||||
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
|
||||
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
|
||||
if deny_deprecated && tag.deprecated?
|
||||
raise DeprecatedTagNormalisationError, [tag.name]
|
||||
end
|
||||
|
||||
tag.update!(category: cat) if cat && tag.category != cat
|
||||
end
|
||||
end
|
||||
@@ -228,4 +245,10 @@ class Tag < ApplicationRecord
|
||||
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
|
||||
end
|
||||
end
|
||||
|
||||
def nico_tags_cannot_be_deprecated
|
||||
if nico? && deprecated_at.present?
|
||||
errors.add :deprecated_at, 'ニコタグは廃止できません.'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,7 +53,7 @@ module PostRepr
|
||||
end
|
||||
|
||||
def tag_json tags
|
||||
tags.map { |tag| TagRepr.inline(tag) }
|
||||
tags.reject(&:deprecated?).map { |tag| TagRepr.inline(tag) }
|
||||
end
|
||||
|
||||
def thumbnail_url post
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
|
||||
module TagRepr
|
||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
|
||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
||||
methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
@@ -16,6 +16,7 @@ class TagVersionRecorder < VersionRecorder
|
||||
def snapshot_attributes
|
||||
{ name: @record.name,
|
||||
category: @record.category,
|
||||
deprecated_at: @record.deprecated_at,
|
||||
aliases: @record.snapshot_aliases.join(' '),
|
||||
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
||||
end
|
||||
|
||||
@@ -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.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -319,6 +319,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do
|
||||
t.string "event_type", null: false
|
||||
t.string "name", null: false
|
||||
t.string "category", null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.text "aliases", null: false
|
||||
t.text "parent_tag_ids", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -336,10 +337,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "post_count", default: 0, null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.datetime "discarded_at"
|
||||
t.integer "version_no", null: false
|
||||
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
||||
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
|
||||
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
|
||||
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
||||
end
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ const mWiki = match<{ title: string }> ('/wiki/:title')
|
||||
const mTag = match<{ id: string }> ('/tags/:id')
|
||||
|
||||
|
||||
const boolFromQuery = (value: string | null): boolean =>
|
||||
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
||||
|
||||
|
||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
||||
const title = url.searchParams.get ('title') ?? ''
|
||||
|
||||
@@ -156,13 +160,16 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
|
||||
const createdTo = url.searchParams.get ('created_to') ?? ''
|
||||
const updatedFrom = url.searchParams.get ('updated_from') ?? ''
|
||||
const updatedTo = url.searchParams.get ('updated_to') ?? ''
|
||||
const deprecated = url.searchParams.has ('deprecated')
|
||||
? boolFromQuery (url.searchParams.get ('deprecated'))
|
||||
: null
|
||||
const page = Number (url.searchParams.get ('page') || 1)
|
||||
const limit = Number (url.searchParams.get ('limit') || 20)
|
||||
const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder
|
||||
|
||||
const keys = {
|
||||
post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||
updatedFrom, updatedTo, page, limit, order }
|
||||
updatedFrom, updatedTo, deprecated, page, limit, order }
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: tagsKeys.index (keys),
|
||||
|
||||
@@ -20,6 +20,7 @@ const baseParams: FetchTagsParams = {
|
||||
createdTo: '',
|
||||
updatedFrom: '',
|
||||
updatedTo: '',
|
||||
deprecated: null,
|
||||
page: 1,
|
||||
limit: 30,
|
||||
order: 'updated_at:desc',
|
||||
|
||||
@@ -10,7 +10,8 @@ import type { Deerjikist,
|
||||
|
||||
export const fetchTags = async (
|
||||
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||
updatedFrom, updatedTo, page, limit, order }: FetchTagsParams,
|
||||
updatedFrom, updatedTo, deprecated,
|
||||
page, limit, order }: FetchTagsParams,
|
||||
): Promise<{ tags: Tag[]
|
||||
count: number }> =>
|
||||
await apiGet ('/tags', { params: {
|
||||
@@ -23,6 +24,7 @@ export const fetchTags = async (
|
||||
...(createdTo && { created_to: createdTo }),
|
||||
...(updatedFrom && { updated_from: updatedFrom }),
|
||||
...(updatedTo && { updated_to: updatedTo }),
|
||||
...(deprecated != null && { deprecated: deprecated ? '1' : '0' }),
|
||||
...(page && { page }),
|
||||
...(limit && { limit }),
|
||||
...(order && { order }) } })
|
||||
|
||||
@@ -19,7 +19,12 @@ import type { FC, FormEvent } from 'react'
|
||||
|
||||
import type { Category, Tag } from '@/types'
|
||||
|
||||
type TagFormField = 'name' | 'category' | 'aliases' | 'parentTags'
|
||||
type TagFormField =
|
||||
| 'name'
|
||||
| 'category'
|
||||
| 'aliases'
|
||||
| 'parentTags'
|
||||
| 'deprecated'
|
||||
|
||||
|
||||
const TagDetailPage: FC = () => {
|
||||
@@ -35,6 +40,7 @@ const TagDetailPage: FC = () => {
|
||||
const [category, setCategory] = useState<Category> ('general')
|
||||
const [aliases, setAliases] = useState ('')
|
||||
const [parentTags, setParentTags] = useState ('')
|
||||
const [deprecated, setDeprecated] = useState (false)
|
||||
const [disabled, setDisabled] = useState (true)
|
||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||
useValidationErrors<TagFormField> ()
|
||||
@@ -50,6 +56,7 @@ const TagDetailPage: FC = () => {
|
||||
formData.append ('category', category)
|
||||
formData.append ('aliases', aliases)
|
||||
formData.append ('parent_tags', parentTags)
|
||||
formData.append ('deprecated', deprecated ? '1' : '0')
|
||||
|
||||
try
|
||||
{
|
||||
@@ -59,6 +66,7 @@ const TagDetailPage: FC = () => {
|
||||
setCategory (data.category as Category)
|
||||
setAliases (data.aliases.join (' '))
|
||||
setParentTags (data.parents.map (t => t.name).join (' '))
|
||||
setDeprecated (Boolean (data.deprecatedAt))
|
||||
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||
@@ -82,6 +90,7 @@ const TagDetailPage: FC = () => {
|
||||
setCategory (tag.category as Category)
|
||||
setAliases (tag.aliases.join (' '))
|
||||
setParentTags (tag.parents.map (t => t.name).join (' '))
|
||||
setDeprecated (Boolean (tag.deprecatedAt))
|
||||
setDisabled (tag.category === 'nico')
|
||||
}, [tag])
|
||||
|
||||
@@ -165,6 +174,17 @@ const TagDetailPage: FC = () => {
|
||||
</>)}
|
||||
</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">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -20,17 +20,28 @@ import type { FC } from 'react'
|
||||
|
||||
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">
|
||||
{diff.prev}
|
||||
{diff.prev && <>{diff.prev}<br/></>}
|
||||
</del>
|
||||
{diff.current && <br/>}
|
||||
</>)}
|
||||
{diff.current}
|
||||
<ins className="text-green-600 dark:text-green-400">
|
||||
{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 location = useLocation ()
|
||||
const query = new URLSearchParams (location.search)
|
||||
@@ -72,6 +83,8 @@ const TagHistoryPage: FC = () => {
|
||||
<col className="w-96"/>
|
||||
{/* カテゴリ */}
|
||||
<col className="w-96"/>
|
||||
{/* 状態 */}
|
||||
<col className="w-32"/>
|
||||
{/* 別名 */}
|
||||
<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>
|
||||
@@ -106,6 +120,9 @@ const TagHistoryPage: FC = () => {
|
||||
prev: (change.category.prev
|
||||
&& CATEGORY_NAMES[change.category.prev]) })}
|
||||
</td>
|
||||
<td className="p-2 break-all">
|
||||
{renderStateDiff (change.deprecatedAt)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.aliases.map ((tag, i) => (
|
||||
tag.type === 'added'
|
||||
@@ -178,6 +195,7 @@ const TagHistoryPage: FC = () => {
|
||||
`/tags/${ change.tagId }`,
|
||||
{ name: change.name.current,
|
||||
category: change.category.current,
|
||||
deprecated: change.deprecatedAt.current ? '1' : '0',
|
||||
aliases:
|
||||
change.aliases
|
||||
.filter (t => t.type !== 'removed')
|
||||
@@ -211,4 +229,5 @@ const TagHistoryPage: FC = () => {
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default TagHistoryPage
|
||||
@@ -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 location = useLocation ()
|
||||
|
||||
@@ -48,6 +55,9 @@ const TagListPage: FC = () => {
|
||||
const qCreatedTo = query.get ('created_to') ?? ''
|
||||
const qUpdatedFrom = query.get ('updated_from') ?? ''
|
||||
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 [name, setName] = useState ('')
|
||||
@@ -58,6 +68,7 @@ const TagListPage: FC = () => {
|
||||
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
||||
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
||||
const [updatedTo, setUpdatedTo] = useState<string | null> (null)
|
||||
const [deprecated, setDeprecated] = useState<boolean | null> (null)
|
||||
|
||||
const keys = {
|
||||
page, limit, order,
|
||||
@@ -69,7 +80,8 @@ const TagListPage: FC = () => {
|
||||
createdFrom: qCreatedFrom,
|
||||
createdTo: qCreatedTo,
|
||||
updatedFrom: qUpdatedFrom,
|
||||
updatedTo: qUpdatedTo }
|
||||
updatedTo: qUpdatedTo,
|
||||
deprecated: qDeprecated }
|
||||
const { data, isLoading: loading } = useQuery ({
|
||||
queryKey: tagsKeys.index (keys),
|
||||
queryFn: () => fetchTags (keys) })
|
||||
@@ -85,10 +97,11 @@ const TagListPage: FC = () => {
|
||||
setCreatedTo (qCreatedTo)
|
||||
setUpdatedFrom (qUpdatedFrom)
|
||||
setUpdatedTo (qUpdatedTo)
|
||||
setDeprecated (qDeprecated)
|
||||
|
||||
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
||||
}, [location.search, qCategory, qCreatedFrom, qCreatedTo, qName, qPostCountGTE,
|
||||
qPostCountLTE, qUpdatedFrom, qUpdatedTo])
|
||||
qPostCountLTE, qUpdatedFrom, qUpdatedTo, qDeprecated])
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault ()
|
||||
@@ -104,6 +117,8 @@ const TagListPage: FC = () => {
|
||||
setIf (qs, 'created_to', createdTo)
|
||||
setIf (qs, 'updated_from', updatedFrom)
|
||||
setIf (qs, 'updated_to', updatedTo)
|
||||
if (deprecated != null)
|
||||
qs.set ('deprecated', deprecated ? '1' : '0')
|
||||
qs.set ('page', '1')
|
||||
qs.set ('order', order)
|
||||
|
||||
@@ -201,6 +216,21 @@ const TagListPage: FC = () => {
|
||||
</>)}
|
||||
</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=""> </option>
|
||||
<option value="0">有効</option>
|
||||
<option value="1">廃止</option>
|
||||
</select>)}
|
||||
</FormField>
|
||||
|
||||
<div className="py-3">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -219,6 +249,7 @@ const TagListPage: FC = () => {
|
||||
<col className="w-72"/>
|
||||
<col className="w-16"/>
|
||||
<col className="w-48"/>
|
||||
<col className="w-32"/>
|
||||
<col className="w-72"/>
|
||||
<col className="w-48"/>
|
||||
<col className="w-56"/>
|
||||
@@ -249,6 +280,7 @@ const TagListPage: FC = () => {
|
||||
currentOrder={order}
|
||||
defaultDirection={defaultDirection}/>
|
||||
</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">
|
||||
@@ -280,6 +312,7 @@ const TagListPage: FC = () => {
|
||||
</td>
|
||||
<td className="p-2 text-right">{row.postCount}</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.parents.map (t => (
|
||||
|
||||
@@ -13,6 +13,7 @@ export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
|
||||
id: 1,
|
||||
name: 'テストタグ',
|
||||
category: 'general',
|
||||
deprecatedAt: null,
|
||||
aliases: [],
|
||||
parents: [],
|
||||
postCount: 12,
|
||||
|
||||
+15
-12
@@ -39,18 +39,19 @@ export type FetchTagsOrderField =
|
||||
| 'updated_at'
|
||||
|
||||
export type FetchTagsParams = {
|
||||
post: number | null
|
||||
name: string
|
||||
category: Category | null
|
||||
postCountGTE: number
|
||||
postCountLTE: number | null
|
||||
createdFrom: string
|
||||
createdTo: string
|
||||
updatedFrom: string
|
||||
updatedTo: string
|
||||
page: number
|
||||
limit: number
|
||||
order: FetchTagsOrder }
|
||||
post: number | null
|
||||
name: string
|
||||
category: Category | null
|
||||
postCountGTE: number
|
||||
postCountLTE: number | null
|
||||
createdFrom: string
|
||||
createdTo: string
|
||||
updatedFrom: string
|
||||
updatedTo: string
|
||||
deprecated: boolean | null
|
||||
page: number
|
||||
limit: number
|
||||
order: FetchTagsOrder }
|
||||
|
||||
export type FetchNicoTagsParams = {
|
||||
name: string
|
||||
@@ -196,6 +197,7 @@ export type Tag = {
|
||||
id: number
|
||||
name: string
|
||||
category: Category
|
||||
deprecatedAt: string | null
|
||||
aliases: string[]
|
||||
parents: Tag[]
|
||||
postCount: number
|
||||
@@ -213,6 +215,7 @@ export type TagVersion = {
|
||||
eventType: 'create' | 'update' | 'discard' | 'restore'
|
||||
name: { current: string; prev: string | null }
|
||||
category: { current: Category; prev: Category | null }
|
||||
deprecatedAt: { current: string | null; prev: string | null }
|
||||
aliases: { name: string; type: 'context' | 'added' | 'removed' }[]
|
||||
parentTags: { tag: Tag; type: 'context' | 'added' | 'removed' }[]
|
||||
createdAt: string
|
||||
|
||||
新しい課題から参照
ユーザをブロックする