diff --git a/AGENTS.md b/AGENTS.md index 58422fe..cc4ad04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,11 +107,16 @@ npm run preview - Prefer single quotes for strings unless interpolation or escaping makes double quotes better. - Ruby: never put a space before method-call parentheses. +- Ruby: `render` 系メソッド呼び出しでは、keyword 引数付きでも括弧を書かない。 - Ruby: never put a line break immediately before `)`. - Ruby: do not use `%w` or `%i`. +- In Ruby, when an `if` condition is split across multiple lines and combines + clauses with `&&` or `||`, wrap the whole condition in parentheses. - Ruby hashes are not blocks; keep `}` on the same line as the final pair. - Ruby hashes keep the first pair on the same line as `{` unless line length requires a break. +- Short Ruby hashes may stay visually compact across two lines with the first + pair kept on the opening line and aligned continuation pairs below it. - Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body indentation. - For arrays, never put whitespace or a line break immediately before `]`. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 9085134..2953f22 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -72,17 +72,23 @@ service, representation, and spec. - Prefer precise, minimal changes. - Use single quotes unless interpolation or escaping makes double quotes better. - Do not put a space before Ruby method-call parentheses. +- For `render`-family method calls, omit parentheses even when passing + keyword arguments. - Never put a line break immediately before `)` in Ruby. - Do not use `%w` or `%i` in new Ruby code. - Never write a Ruby line longer than 99 characters. - Aim to keep Ruby lines within 79 characters where practical. - For small Ruby method definitions that take keyword arguments, match the local no-parentheses style when nearby code uses it. +- When an `if` condition is split across multiple lines and combines clauses + with `&&` or `||`, wrap the whole condition in parentheses. - Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate rules. - Do not format Ruby hashes like Ruby blocks. - For Ruby hashes, keep the closing `}` on the same line as the final pair. - Keep the first pair on the same line as `{` by default. +- Short Ruby hashes may stay visually compact across two lines with the first + pair kept on the opening line and aligned continuation pairs below it. - If the hash would exceed the line limit, break after `{` and indent pairs by 4 spaces. - Put one logical pair per line when the expression would otherwise become diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index 0958c75..243a548 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -17,6 +17,7 @@ class TagVersionsController < ApplicationController AND prev.version_no = tag_versions.version_no - 1 SQL .select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category', + 'prev.deprecated_at AS prev_deprecated_at', 'prev.aliases AS prev_aliases', 'prev.parent_tag_ids AS prev_parent_tag_ids') q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id @@ -62,6 +63,8 @@ class TagVersionsController < ApplicationController event_type: row.event_type, name: { current: row.name, prev: row.attributes['prev_name'] }, category: { current: row.category, prev: row.attributes['prev_category'] }, + deprecated_at: { current: row.deprecated_at&.iso8601, + prev: row.attributes['prev_deprecated_at']&.iso8601 }, aliases: build_version_values(cur_aliases, prev_aliases, key: :name), parent_tags:, created_at: row.created_at.iso8601, diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 30d822b..b9fadb9 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -14,6 +14,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 +50,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] @@ -79,9 +84,21 @@ class TagsController < ApplicationController tag_ids = if parent_tag_id - TagImplication.where(parent_tag_id:).select(:tag_id) + TagImplication.joins(:tag) + .where(parent_tag_id:) + .where(tags: { deprecated_at: nil }) + .select(:tag_id) else - Tag.where.not(id: TagImplication.select(:tag_id)).select(:id) + Tag.where(deprecated_at: nil) + .where.not(id: TagImplication + .joins(<<~SQL.squish) + INNER JOIN + tags parent_tags + ON parent_tags.id = tag_implications.parent_tag_id + SQL + .where('parent_tags.deprecated_at IS NULL') + .select(:tag_id)) + .select(:id) end tags = @@ -89,6 +106,7 @@ class TagsController < ApplicationController .joins(:tag_name) .includes(:tag_name, :materials, tag_name: :wiki_page) .where(category: [:meme, :character, :material]) + .where(deprecated_at: nil) .where(id: tag_ids) .order('tag_names.name') .distinct @@ -101,7 +119,8 @@ class TagsController < ApplicationController TagImplication .joins(:tag) .where(parent_tag_id: tags.map(&:id), - tags: { category: [:meme, :character, :material] }) + tags: { category: [:meme, :character, :material], + deprecated_at: nil }) .distinct .pluck(:parent_tag_id) end @@ -133,6 +152,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 +272,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 +298,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 +330,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 +352,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 diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 5048e45..5d78c75 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -58,6 +58,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 +78,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 @@ -228,4 +231,10 @@ class Tag < ApplicationRecord errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' end end + + def nico_tags_cannot_be_deprecated + if nico? && deprecated_at.present? + errors.add :deprecated_at, 'ニコタグは廃止できません.' + end + end end diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index 28be332..3958953 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -2,7 +2,7 @@ module TagRepr - BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], + BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at], methods: [:name, :has_wiki, :material_id, :has_deerjikists] }.freeze module_function diff --git a/backend/app/services/tag_version_recorder.rb b/backend/app/services/tag_version_recorder.rb index fe2b0c1..a786058 100644 --- a/backend/app/services/tag_version_recorder.rb +++ b/backend/app/services/tag_version_recorder.rb @@ -16,6 +16,7 @@ class TagVersionRecorder < VersionRecorder def snapshot_attributes { name: @record.name, category: @record.category, + deprecated_at: @record.deprecated_at, aliases: @record.snapshot_aliases.join(' '), parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } end diff --git a/backend/db/migrate/20260621000000_add_deprecated_at_to_tags.rb b/backend/db/migrate/20260621000000_add_deprecated_at_to_tags.rb new file mode 100644 index 0000000..4456f88 --- /dev/null +++ b/backend/db/migrate/20260621000000_add_deprecated_at_to_tags.rb @@ -0,0 +1,20 @@ +class AddDeprecatedAtToTags < ActiveRecord::Migration[8.0] + def up + add_column :tags, :deprecated_at, :datetime, after: :category + add_column :tag_versions, :deprecated_at, :datetime, after: :parent_tag_ids + + add_index :tags, :deprecated_at + + add_check_constraint :tags, "deprecated_at IS NULL OR category <> 'nico'", + name: 'chk_tags_deprecated_at_not_nico' + end + + def down + remove_check_constraint :tags, name: 'chk_tags_deprecated_at_not_nico' + + remove_index :tags, :deprecated_at + + remove_column :tag_versions, :deprecated_at, :datetime + remove_column :tags, :deprecated_at + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 413adae..8c988e5 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_21_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -319,6 +319,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do t.string "event_type", null: false t.string "name", null: false t.string "category", null: false + t.datetime "deprecated_at" t.text "aliases", null: false t.text "parent_tag_ids", null: false t.datetime "created_at", null: false @@ -336,10 +337,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_12_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "post_count", default: 0, null: false + t.datetime "deprecated_at" t.datetime "discarded_at" t.integer "version_no", null: false + t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true + t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 38f51cc..264a380 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -17,6 +17,10 @@ const mWiki = match<{ title: string }> ('/wiki/:title') const mTag = match<{ id: string }> ('/tags/:id') +const boolFromQuery = (value: string | null): boolean => + ['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ()) + + const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { const title = url.searchParams.get ('title') ?? '' @@ -156,13 +160,16 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => { const createdTo = url.searchParams.get ('created_to') ?? '' const updatedFrom = url.searchParams.get ('updated_from') ?? '' const updatedTo = url.searchParams.get ('updated_to') ?? '' + const deprecated = url.searchParams.has ('deprecated') + ? boolFromQuery (url.searchParams.get ('deprecated')) + : null const page = Number (url.searchParams.get ('page') || 1) const limit = Number (url.searchParams.get ('limit') || 20) const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder const keys = { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, - updatedFrom, updatedTo, page, limit, order } + updatedFrom, updatedTo, deprecated, page, limit, order } await qc.prefetchQuery ({ queryKey: tagsKeys.index (keys), diff --git a/frontend/src/lib/tags.test.ts b/frontend/src/lib/tags.test.ts index f51455c..4f65360 100644 --- a/frontend/src/lib/tags.test.ts +++ b/frontend/src/lib/tags.test.ts @@ -20,6 +20,7 @@ const baseParams: FetchTagsParams = { createdTo: '', updatedFrom: '', updatedTo: '', + deprecated: null, page: 1, limit: 30, order: 'updated_at:desc', diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 9ac8788..6ec70df 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -10,7 +10,8 @@ import type { Deerjikist, export const fetchTags = async ( { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, - updatedFrom, updatedTo, page, limit, order }: FetchTagsParams, + updatedFrom, updatedTo, deprecated, + page, limit, order }: FetchTagsParams, ): Promise<{ tags: Tag[] count: number }> => await apiGet ('/tags', { params: { @@ -23,6 +24,7 @@ export const fetchTags = async ( ...(createdTo && { created_to: createdTo }), ...(updatedFrom && { updated_from: updatedFrom }), ...(updatedTo && { updated_to: updatedTo }), + ...(deprecated != null && { deprecated: deprecated ? '1' : '0' }), ...(page && { page }), ...(limit && { limit }), ...(order && { order }) } }) diff --git a/frontend/src/pages/tags/TagDetailPage.tsx b/frontend/src/pages/tags/TagDetailPage.tsx index 386208e..d1e3e76 100644 --- a/frontend/src/pages/tags/TagDetailPage.tsx +++ b/frontend/src/pages/tags/TagDetailPage.tsx @@ -19,7 +19,12 @@ import type { FC, FormEvent } from 'react' import type { Category, Tag } from '@/types' -type TagFormField = 'name' | 'category' | 'aliases' | 'parentTags' +type TagFormField = + | 'name' + | 'category' + | 'aliases' + | 'parentTags' + | 'deprecated' const TagDetailPage: FC = () => { @@ -35,6 +40,7 @@ const TagDetailPage: FC = () => { const [category, setCategory] = useState ('general') const [aliases, setAliases] = useState ('') const [parentTags, setParentTags] = useState ('') + const [deprecated, setDeprecated] = useState (false) const [disabled, setDisabled] = useState (true) const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () @@ -50,6 +56,7 @@ const TagDetailPage: FC = () => { formData.append ('category', category) formData.append ('aliases', aliases) formData.append ('parent_tags', parentTags) + formData.append ('deprecated', deprecated ? '1' : '0') try { @@ -59,6 +66,7 @@ const TagDetailPage: FC = () => { setCategory (data.category as Category) setAliases (data.aliases.join (' ')) setParentTags (data.parents.map (t => t.name).join (' ')) + setDeprecated (Boolean (data.deprecatedAt)) qc.invalidateQueries ({ queryKey: postsKeys.root }) qc.invalidateQueries ({ queryKey: tagsKeys.root }) @@ -82,6 +90,7 @@ const TagDetailPage: FC = () => { setCategory (tag.category as Category) setAliases (tag.aliases.join (' ')) setParentTags (tag.parents.map (t => t.name).join (' ')) + setDeprecated (Boolean (tag.deprecatedAt)) setDisabled (tag.category === 'nico') }, [tag]) @@ -165,6 +174,17 @@ const TagDetailPage: FC = () => { )} + + {({ describedBy, invalid }) => ( + setDeprecated (e.target.checked)} + aria-describedby={describedBy} + aria-invalid={invalid}/>)} + +