コミットを比較

..
2 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker d6fc57a00e AI: address issue #479 2026-11-16 00:18:56 +09:00
みてるぞとCodex Night Worker dda3d87073 外部タグの履歴管理方式を変更 (#445) (#480)
Co-authored-by: Codex Night Worker <codex-night@localhost>
Reviewed-on: #480
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-15 16:18:27 +09:00
55個のファイルの変更、3870行の追加、465行の削除
-35
ファイルの表示
@@ -1,35 +0,0 @@
## 背景
なぜ必要か。
## 対象範囲
- backend:
- frontend:
- docs:
- migration:
## やること
- [ ]
## 受け入れ条件
- [ ]
## 実行すべき確認
- [ ] `cd backend && bundle exec rspec`
- [ ] `cd frontend && npm run build`
- [ ] `cd frontend && npm run lint`
## 禁止事項
- unrelated refactor はしない
- 既存 API response shape を壊さない
- 認証・認可・BAN を弱めない
## Codex への指示
この issue を読んで実装してください。
不明点があれば、実装前に調査結果と選択肢を提示してください。
+1 -1
ファイルの表示
@@ -424,7 +424,7 @@ races according to the target constraint's business meaning.
and JSON keys are generally snake_case. and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions: - Preserve existing HTTP status conventions:
`:unauthorized` for no user, `:forbidden` for insufficient role or banned `:unauthorized` for no user, `:forbidden` for insufficient role or banned
user, `:not_found` for missing records, and `:unprocessable_entity` for user, `:not_found` for missing records, and `:unprocessable_content` for
validation failures. validation failures.
- For diagnostic or internal helper JSON, prefer a deliberately light response - For diagnostic or internal helper JSON, prefer a deliberately light response
shape over full representation classes when callers only need identifiers, shape over full representation classes when callers only need identifiers,
+1 -1
ファイルの表示
@@ -95,7 +95,7 @@ class ApplicationController < ActionController::API
render_validation_error base: ['すでに存在してゐます.'] render_validation_error base: ['すでに存在してゐます.']
end end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_content
errors = { } errors = { }
if record if record
+3 -3
ファイルの表示
@@ -18,7 +18,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS }, render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity status: :unprocessable_content
rescue ActiveRecord::StatementTimeout rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError rescue ArgumentError, TypeError, KeyError, JSON::ParserError
@@ -63,7 +63,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS }, render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity status: :unprocessable_content
rescue ActiveRecord::StatementTimeout rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError rescue ArgumentError, TypeError, KeyError, JSON::ParserError
@@ -107,7 +107,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS }, render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity status: :unprocessable_content
rescue ActiveRecord::StatementTimeout rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError rescue ArgumentError, TypeError, KeyError, JSON::ParserError
+1 -1
ファイルの表示
@@ -24,7 +24,7 @@ class CountsTablesController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS }, render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity status: :unprocessable_content
rescue CountsPivotTotals::TimedOut rescue CountsPivotTotals::TimedOut
Rails.logger.warn('counts_query_timeout: pivot_totals') Rails.logger.warn('counts_query_timeout: pivot_totals')
render json: { error: 'counts_query_timeout', stage: 'totals', render json: { error: 'counts_query_timeout', stage: 'totals',
+2 -2
ファイルの表示
@@ -15,7 +15,7 @@ class GekanatorGamesController < ApplicationController
answers:) answers:)
if game.invalid? if game.invalid?
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity render json: { errors: game.errors.full_messages }, status: :unprocessable_content
return return
end end
@@ -31,7 +31,7 @@ class GekanatorGamesController < ApplicationController
learned_example_count: learned_example_count:
}, status: :created }, status: :created
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content
end end
def extra_questions def extra_questions
+1 -1
ファイルの表示
@@ -536,7 +536,7 @@ class MaterialsController < ApplicationController
errors: { }, errors: { },
base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'], base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'],
missing_files: }, missing_files: },
status: :unprocessable_entity status: :unprocessable_content
end end
def build_uploaded_material_blob! file, file_sha256 def build_uploaded_material_blob! file, file_sha256
+1 -10
ファイルの表示
@@ -100,18 +100,9 @@ class PostVersionsController < ApplicationController
def external_tag_names_for snapshots def external_tag_names_for snapshots
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name| ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
[id, "#{ platform }:#{ name }"] [id, "#{ platform }:#{ name }"]
} }
missing_ids = ids - names.keys
HistoryAccess.visible(NicoTagVersion.all)
.where(tag_id: missing_ids)
.order(:tag_id, version_no: :desc)
.pluck(:tag_id, :name)
.each { |id, name| names[id] ||= name }
names
end end
def snapshot_tag_literals snapshots, tag_names, external_tag_names def snapshot_tag_literals snapshots, tag_names, external_tag_names
+2 -2
ファイルの表示
@@ -429,7 +429,7 @@ class PostsController < ApplicationController
filters = literals.map do |literal| filters = literals.map do |literal|
internal, = source_ids.fetch(literal[:name]) internal, = source_ids.fetch(literal[:name])
name = internal.empty? ? literal[:name] : names[internal.first] name = internal.empty? ? literal[:name] : names[internal.first]
return head :unprocessable_entity unless name && internal.length <= 1 return head :unprocessable_content unless name && internal.length <= 1
literal[:negative] ? "not:#{ name }" : name literal[:negative] ? "not:#{ name }" : name
end end
@@ -438,7 +438,7 @@ class PostsController < ApplicationController
literals.zip(translated).each do |literal, translation| literals.zip(translated).each do |literal, translation|
unless (source_ids.fetch(literal[:name]).map(&:sort) == unless (source_ids.fetch(literal[:name]).map(&:sort) ==
target_ids.fetch(translation[:name]).map(&:sort)) target_ids.fetch(translation[:name]).map(&:sort))
return head :unprocessable_entity return head :unprocessable_content
end end
end end
index = 0 index = 0
+1 -64
ファイルの表示
@@ -1,34 +1,4 @@
class TagDescriptionsController < ApplicationController class TagDescriptionsController < ApplicationController
rescue_from TagDescriptionOperation::Conflict, with: :render_conflict
def index
locale = resolve_locale!
descriptions = HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
.where(language_code: locale.language_code).includes(tag: :tag_names)
.order(updated_at: :desc, id: :asc)
title = params[:title].to_s.strip
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions.joins(tag: :tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
.where('tag_names.name LIKE ?', pattern).limit(20)
end
render json: descriptions.map { WikiPageRepr.tag_description(locale, _1) }
end
def show_by_id
locale = resolve_locale!
reference = TagDescription.find_by(id: params[:description_id])
return head :not_found unless reference
HistoryAccess.require_current!(reference, TagDescriptionVersion)
description = TagDescription.find_by(tag_id: reference.tag_id,
language_code: locale.language_code)
return head :not_found unless description
render json: representation(description)
end
def show def show
locale = resolve_locale! locale = resolve_locale!
description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code) description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code)
@@ -48,25 +18,6 @@ class TagDescriptionsController < ApplicationController
render json: representation(description) render json: representation(description)
end end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
title = params[:title].to_s.strip
body = params[:body].to_s
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
return render_unprocessable_entity field: :title if title.include?(':')
tag_name = TagName.find_by(language_code: locale.language_code, name: title)
return render_unprocessable_entity field: :title unless tag_name&.tag
description = TagDescriptionOperation.create!(
locale, tag: tag_name.tag, body:, created_by_user: current_user)
render json: representation(description), status: :created
end
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
@@ -81,9 +32,7 @@ class TagDescriptionsController < ApplicationController
description = TagDescription.find_by(tag:, language_code: locale.language_code) description = TagDescription.find_by(tag:, language_code: locale.language_code)
if description if description
TagDescriptionOperation.update_with_title!( TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
locale, tag:, body:, title: params[:title], actor: current_user,
base_revision_id: params[:base_revision_id].presence)
description.reload description.reload
render json: representation(description) render json: representation(description)
else else
@@ -99,20 +48,8 @@ class TagDescriptionsController < ApplicationController
private private
def render_conflict error
render json: { error: 'conflict', message: error.message }, status: :conflict
end
def representation description def representation description
HistoryAccess.require_current!(description, TagDescriptionVersion) HistoryAccess.require_current!(description, TagDescriptionVersion)
if params[:document] == '1'
locale = resolve_locale!
version = HistoryAccess.visible(TagDescriptionVersion.all).find_by!(
tag_id: description.tag_id, language_code: description.language_code,
version_no: description.version_no)
return WikiPageRepr.tag_description(locale, description).merge(revision_id: version.id)
end
description.as_json(only: [ description.as_json(only: [
:tag_id, :tag_id,
:language_code, :language_code,
+7 -56
ファイルの表示
@@ -1,22 +1,7 @@
class TagGroupsController < ApplicationController class TagGroupsController < ApplicationController
rescue_from TagGroupMarkdownParser::InvalidDocument,
TagGroupMarkdownRenderer::UnsupportedStructure, with: :render_document_error
def index def index
locale = resolve_locale! locale = resolve_locale!
groups = HistoryAccess.current(TagGroup.all, TagGroupVersion) groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).includes(:created_by_user, :updated_by_user).order(:id)
.includes(:created_by_user, :updated_by_user)
if params[:document] == '1'
groups = groups.order(updated_at: :desc, id: :asc)
title = params[:title].to_s.strip
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
groups = groups.where('CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?',
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
end
return render json: groups.map { WikiPageRepr.tag_group(_1) }
end
groups = groups.order(:id)
render json: TagGroupRepr.many(locale, groups.to_a) render json: TagGroupRepr.many(locale, groups.to_a)
end end
@@ -25,15 +10,7 @@ class TagGroupsController < ApplicationController
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id]) group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
return head :not_found unless group return head :not_found unless group
render json: representation(locale, group) render json: TagGroupRepr.one(locale, group)
end
def show_by_name
locale = resolve_locale!
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(name: params[:name])
return head :not_found unless group
render json: representation(locale, group)
end end
def create def create
@@ -41,8 +18,8 @@ class TagGroupsController < ApplicationController
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
locale = resolve_locale! locale = resolve_locale!
group = TagGroupOperation.create!(locale, **write_attributes(locale), actor: current_user) group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
render json: representation(locale, group), status: :created render json: TagGroupRepr.one(locale, group), status: :created
end end
def update def update
@@ -53,39 +30,13 @@ class TagGroupsController < ApplicationController
group = TagGroup.find_by(id: params[:id]) group = TagGroup.find_by(id: params[:id])
return head :not_found unless group return head :not_found unless group
TagGroupOperation.update!(locale, group:, **write_attributes(locale, group:), actor: current_user) TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
render json: representation(locale, group.reload) render json: TagGroupRepr.one(locale, group.reload)
end end
private private
def render_document_error error def write_attributes
render_unprocessable_entity(error.message)
end
def representation locale, group
representation = TagGroupRepr.one(locale, group)
return representation unless params[:document] == '1'
version = TagGroupRepr.current_version(group)
raise ActiveRecord::RecordNotFound unless version
TagGroupRepr.document(representation, version)
end
def write_attributes locale, group: nil
if params.key?(:body)
body = params[:body].to_s
if body.blank?
group ||= TagGroup.new
group.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, group
end
parsed = TagGroupMarkdownParser.parse(locale, body:, group:)
return parsed.except(:compatibility_body).merge(
name: params[:name], require_compatibility: true)
end
{ name: params[:name], description: params[:description], { name: params[:name], description: params[:description],
members: params[:members], placements: params[:placements] } members: params[:members], placements: params[:placements] }
end end
+2 -2
ファイルの表示
@@ -55,7 +55,7 @@ class TheatresController < ApplicationController
theatre = Theatre.find_by(id: params[:id]) theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre return head :not_found unless theatre
requested_post_id = params[:post_id].to_i requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0 return head :unprocessable_content if requested_post_id <= 0
skipped = false skipped = false
conflicted = false conflicted = false
@@ -95,7 +95,7 @@ class TheatresController < ApplicationController
theatre = Theatre.find_by(id: params[:id]) theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre return head :not_found unless theatre
requested_post_id = params[:post_id].to_i requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0 return head :unprocessable_content if requested_post_id <= 0
conflicted = false conflicted = false
+34 -12
ファイルの表示
@@ -6,11 +6,6 @@ class WikiPagesController < ApplicationController
def index def index
locale = resolve_locale! locale = resolve_locale!
resource_type = params[:resource_type]
unless resource_type.nil? || ['tag_description', 'tag_group'].include?(resource_type)
return head :bad_request
end
title = params[:title].to_s.strip title = params[:title].to_s.strip
descriptions = descriptions =
HistoryAccess.current(TagDescription.all, TagDescriptionVersion) HistoryAccess.current(TagDescription.all, TagDescriptionVersion)
@@ -19,8 +14,6 @@ class WikiPagesController < ApplicationController
descriptions = descriptions.order(updated_at: :desc, id: :asc) descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc) tag_groups = HistoryAccess.current(TagGroup.all, TagGroupVersion).order(updated_at: :desc, id: :asc)
descriptions = descriptions.none if resource_type == 'tag_group'
tag_groups = tag_groups.none if resource_type == 'tag_description'
if title.present? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions =
@@ -253,8 +246,25 @@ class WikiPagesController < ApplicationController
base_revision_id = params[:base_revision_id].presence base_revision_id = params[:base_revision_id].presence
TagDescriptionOperation.update_with_title!( ApplicationRecord.transaction do
locale, tag:, body:, title:, actor: current_user, base_revision_id:) SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
old_title = tag_name.name
if title != old_title
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
end
tag_name.update!(name: title) if title != old_title
message = params[:message].presence
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
message:, base_revision_id:)
if title != old_title
tag.reload
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end
end
head :ok head :ok
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@@ -316,9 +326,21 @@ class WikiPagesController < ApplicationController
end end
def render_group_representation representation, version, body: nil, status: :ok def render_group_representation representation, version, body: nil, status: :ok
document = TagGroupRepr.document(representation, version) body ||= TagGroupMarkdownRenderer.render(representation)
document['body'] = body if body versions = HistoryAccess.visible(TagGroupVersion.all).where(tag_group_id: version.tag_group_id)
render json: document, status: pred = versions.find_by(version_no: version.version_no - 1)&.id
succ = versions.find_by(version_no: version.version_no + 1)&.id
updated_at = version.created_at
render json: {
'id' => version.tag_group_id,
'resource_type' => 'tag_group',
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
'deprecated_at' => nil,
'body' => body,
'revision_id' => version.id,
'pred' => pred,
'succ' => succ,
'updated_at' => updated_at }, status:
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
render_unprocessable_entity(error.message) render_unprocessable_entity(error.message)
end end
+19 -6
ファイルの表示
@@ -1,8 +1,11 @@
class ExternalTag < ApplicationRecord class ExternalTag < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) } before_create { SharedResourceIdAllocator.assign!(self) }
# Must run before dependent association callbacks.
before_destroy :prevent_destroy
enum :platform, nico: 'nico' enum :platform, nico: 'nico'
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys } validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
validate :immutable_identity, on: :update
has_many :post_external_tags, dependent: :delete_all has_many :post_external_tags, dependent: :delete_all
has_many :posts, through: :post_external_tags has_many :posts, through: :post_external_tags
@@ -16,11 +19,21 @@ class ExternalTag < ApplicationRecord
has_many :linked_tags, through: :nico_tag_relations, source: :tag has_many :linked_tags, through: :nico_tag_relations, source: :tag
def snapshot_linked_tag_names language_code = 'ja' def snapshot_linked_tag_ids = linked_tags.distinct.order(:id).pluck(:id)
linked_tags
.joins(:tag_names) def delete
.where(tag_names: { language_code:, primary_flg: true }) raise ActiveRecord::ReadOnlyRecord, '外部タグは削除できません.'
.order('tag_names.name') end
.pluck('tag_names.name')
private
def immutable_identity
errors.add(:platform, 'は変更できません.') if will_save_change_to_platform?
errors.add(:name, 'は変更できません.') if will_save_change_to_name?
end
def prevent_destroy
errors.add(:base, '外部タグは削除できません.')
throw(:abort)
end end
end end
+1 -15
ファイルの表示
@@ -106,21 +106,7 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
snapshot_reference(TagVersion, row), snapshot_reference(TagVersion, row),
snapshot_reference(NicoTagVersion, row, external: true)] snapshot_reference(NicoTagVersion, row, external: true)]
when 'nico_tag_versions' when 'nico_tag_versions'
# Legacy linked_tags snapshots contain names, not IDs. Match recorded names [list_reference('tag_versions', 'tag_id', "#{ row }.linked_tag_ids")]
# conservatively; never expose a private linked name through an external tag.
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
# DISTINCT prevents merging this independent name set into the correlated scan.
# Keep byte-distinct names even when the column collation considers them equal.
private_names = 'SELECT DISTINCT private_name.name, ' +
'CAST(private_name.name AS BINARY) AS name_bytes FROM tag_versions private_tag ' +
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
'ON blocked.tag_id = private_tag.tag_id ' +
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
"WHERE private_name.name <> ''"
["NOT EXISTS (SELECT 1 FROM (#{ private_names }) private_name WHERE " +
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
when 'tag_versions' when 'tag_versions'
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")] [list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
when 'tag_description_versions' when 'tag_description_versions'
+6 -1
ファイルの表示
@@ -3,5 +3,10 @@ class NicoTagVersion < ApplicationRecord
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
validates :name, presence: true validates :event_type, inclusion: { in: ['create', 'update'] }
def name
tag = external_tag
"#{ tag.platform }:#{ tag.name }" if tag
end
end end
+4 -1
ファイルの表示
@@ -139,6 +139,8 @@ class CountsVersionRepr
result = {} result = {}
table = model.table_name table = model.table_name
comparison = @inclusive ? '<=' : '<' comparison = @inclusive ? '<=' : '<'
name = model == NicoTagVersion ?
CountsResourceDefinition.new('nico_tags').expression('name', table) : 'name'
requests.each_slice(100) do |batch| requests.each_slice(100) do |batch|
queries = batch.map do |id, point| queries = batch.map do |id, point|
scope = HistoryAccess.visible(model.where(tag_id: id)) scope = HistoryAccess.visible(model.where(tag_id: id))
@@ -148,7 +150,8 @@ class CountsVersionRepr
"AND newer.version_no > #{ table }.version_no)", point) "AND newer.version_no > #{ table }.version_no)", point)
quoted = model.connection.quote(point.iso8601(6)) quoted = model.connection.quote(point.iso8601(6))
names = model == TagVersion ? 'names' : 'JSON_OBJECT() AS names' names = model == TagVersion ? 'names' : 'JSON_OBJECT() AS names'
scope.select("tag_id, name, #{ names }, #{ quoted } AS reference_point").to_sql scope.select("tag_id, #{ name } AS name, #{ names }, " +
"#{ quoted } AS reference_point").to_sql
end end
next if queries.empty? next if queries.empty?
-15
ファイルの表示
@@ -56,21 +56,6 @@ module TagGroupRepr
'updated_at' => wiki_page.updated_at } 'updated_at' => wiki_page.updated_at }
end end
def document representation, version
HistoryAccess.require!(version)
versions = HistoryAccess.visible(TagGroupVersion.all)
.where(tag_group_id: version.tag_group_id)
{ 'id' => version.tag_group_id,
'resource_type' => 'tag_group',
'title' => "#{ PREFIX }#{ representation.fetch('name') }",
'deprecated_at' => nil,
'body' => TagGroupMarkdownRenderer.render(representation),
'revision_id' => version.id,
'pred' => versions.find_by(version_no: version.version_no - 1)&.id,
'succ' => versions.find_by(version_no: version.version_no + 1)&.id,
'updated_at' => version.created_at }
end
def current_version group def current_version group
HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no) HistoryAccess.visible(TagGroupVersion.all).find_by(tag_group_id: group.id, version_no: group.version_no)
end end
+4 -2
ファイルの表示
@@ -28,16 +28,18 @@ class CountsCandidates
[external, 'external', 'NULL', 'JSON_OBJECT()']] [external, 'external', 'NULL', 'JSON_OBJECT()']]
sources = sources.map do |population, kind, category, names| sources = sources.map do |population, kind, category, names|
scope = population.scope.except(:select) scope = population.scope.except(:select)
name = population.definition.expression('name')
if @params['query'].present? if @params['query'].present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" + scope = scope.where("(#{ name } LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " + "SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " + "JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern) 'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
end end
scope.select(Arel.sql( scope.select(Arel.sql(
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " + "CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind")) "#{ name } AS name, #{ names } AS names, " +
"#{ category } AS category, '#{ kind }' AS kind"))
.to_sql .to_sql
end end
clauses = [] clauses = []
+1 -1
ファイルの表示
@@ -113,7 +113,7 @@ class CountsPopulation
else else
raise ArgumentError unless definition.axes(history).include?(key) raise ArgumentError unless definition.axes(history).include?(key)
scope = scope.where(definition.expression(key) => entry['value']) scope = scope.where("#{ definition.expression(key) } <=> ?", entry['value'])
end end
scope scope
end end
+18 -3
ファイルの表示
@@ -105,12 +105,25 @@ class CountsResourceDefinition
def time_column = 'created_at' def time_column = 'created_at'
def column(key) def column(key)
raise ArgumentError unless fields.key?(key) raise ArgumentError unless fields.key?(key)
# Keep the V1 Counts field name at the API boundary only.
return 'linked_tag_ids' if resource == 'nico_tags' && key == 'linked_tags'
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key) { 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
end end
def expression(key, row = 'v') = "#{ row }.#{ column(key) }" def expression(key, row = 'v')
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id'] if resource == 'nico_tags' && key == 'name'
return "(SELECT CONCAT(external_tags.platform, ':', external_tags.name) " +
"FROM external_tags WHERE external_tags.id = #{ row }.tag_id)"
end
"#{ row }.#{ column(key) }"
end
def changes
columns = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
resource == 'nico_tags' ? columns - ['name'] : columns
end
def axes(history) def axes(history)
fields.reject { |_key, type| type == :json }.keys + fields.reject { |_key, type| type == :json }.keys +
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : []) (resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
@@ -118,7 +131,9 @@ class CountsResourceDefinition
def metadata def metadata
fields.map do |key, type| fields.map do |key, type|
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null, nullable = (resource == 'nico_tags' && key == 'name') ||
model.columns_hash.fetch(column(key)).null
{ key:, type:, nullable:,
operators: OPERATORS.fetch(type, []) } operators: OPERATORS.fetch(type, []) }
end end
end end
+3 -1
ファイルの表示
@@ -201,6 +201,8 @@ class CountsStateQuery
end end
def listing def listing
external_name = CountsResourceDefinition.new('nico_tags')
.expression('name', 'external_version')
catalog = <<~SQL catalog = <<~SQL
, tag_counts AS ( , tag_counts AS (
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
@@ -209,7 +211,7 @@ class CountsStateQuery
SELECT 'internal' AS kind, tag_id, name, names, SELECT 'internal' AS kind, tag_id, name, names,
deprecated_at FROM tag_at WHERE event_type <> 'discard' deprecated_at FROM tag_at WHERE event_type <> 'discard'
UNION ALL UNION ALL
SELECT tc.kind, tc.tag_id, external_version.name, JSON_OBJECT() AS names, SELECT tc.kind, tc.tag_id, #{ external_name } AS name, JSON_OBJECT() AS names,
NULL AS deprecated_at NULL AS deprecated_at
FROM tag_counts tc FROM tag_counts tc
LEFT JOIN tag_at internal_version LEFT JOIN tag_at internal_version
+2
ファイルの表示
@@ -182,6 +182,7 @@ class CountsTableQuery
order = sort_sql { detail_expression(_1) } order = sort_sql { detail_expression(_1) }
query = query.order(Arel.sql((order + ['v.id ASC']).join(', '))) query = query.order(Arel.sql((order + ['v.id ASC']).join(', ')))
.limit(limit).offset((page - 1) * limit) .limit(limit).offset((page - 1) * limit)
query = query.preload(:external_tag) if @definition.resource == 'nico_tags'
rows = query.to_a rows = query.to_a
if ['posts', 'tags'].include?(@definition.resource) if ['posts', 'tags'].include?(@definition.resource)
point = population.history ? population.at : point = population.history ? population.at :
@@ -486,6 +487,7 @@ class CountsTableQuery
table = model.table_name table = model.table_name
scope = HistoryAccess.visible(model.where(tag_id: ids)).where('created_at <= ?', point) scope = HistoryAccess.visible(model.where(tag_id: ids)).where('created_at <= ?', point)
scope = scope.includes(:external_tag) if model == NicoTagVersion
bound = @connection.quote(point) bound = @connection.quote(point)
if population.history && @definition.resource == 'posts' if population.history && @definition.resource == 'posts'
key = kind == 'internal' ? 'tag_id' : 'external_tag_id' key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
+2 -2
ファイルの表示
@@ -1,4 +1,5 @@
class NicoTagVersionRecorder < VersionRecorder class NicoTagVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update'].freeze
def self.record! external_tag:, event_type:, created_by_user: def self.record! external_tag:, event_type:, created_by_user:
new(external_tag:, event_type:, created_by_user:).record! new(external_tag:, event_type:, created_by_user:).record!
end end
@@ -14,8 +15,7 @@ class NicoTagVersionRecorder < VersionRecorder
def record_key = :external_tag def record_key = :external_tag
def snapshot_attributes def snapshot_attributes
{ name: "#{ @record.platform }:#{ @record.name }", { linked_tag_ids: @record.snapshot_linked_tag_ids.join(' ') }
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end end
def tracks_version_no_on_record? = false def tracks_version_no_on_record? = false
-31
ファイルの表示
@@ -37,37 +37,6 @@ class TagDescriptionOperation
end end
end end
def self.update_with_title! locale, tag:, body:, title:, actor:, base_revision_id: nil
return update!(locale, tag:, body:, updated_by_user: actor,
base_revision_id:) if title.nil?
title = title.to_s.strip
tag_name = tag.tag_name(locale.language_code)
if title.blank? || title.include?(':') || !tag_name || tag_name.name.include?(':')
tag.errors.add(:title, :invalid)
raise ActiveRecord::RecordInvalid, tag
end
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
old_title = tag_name.name
if title != old_title
TagVersioning.ensure_snapshot!(tag, created_by_user: actor)
end
tag_name.update!(name: title) if title != old_title
update!(locale, tag:, body:, updated_by_user: actor,
base_revision_id:)
if title != old_title
tag.reload
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: actor)
end
end
end
def self.record_version! description:, body:, user: def self.record_version! description:, body:, user:
creating = description.new_record? creating = description.new_record?
version_no = creating ? 1 : description.version_no + 1 version_no = creating ? 1 : description.version_no + 1
+1 -8
ファイルの表示
@@ -17,11 +17,7 @@ Rails.application.routes.draw do
resources :locales, only: [:index] resources :locales, only: [:index]
resources :sitemap_entries, only: [:index, :show], param: :resource resources :sitemap_entries, only: [:index, :show], param: :resource
resources :tag_groups, only: [:index, :show, :create, :update] do resources :tag_groups, only: [:index, :show, :create, :update]
collection do
get 'name/:name', action: :show_by_name
end
end
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
@@ -32,9 +28,6 @@ Rails.application.routes.draw do
resources :tags, only: [:index, :show] do resources :tags, only: [:index, :show] do
collection do collection do
get :descriptions, to: 'tag_descriptions#index'
post :descriptions, to: 'tag_descriptions#create'
get 'descriptions/:description_id', to: 'tag_descriptions#show_by_id'
get :autocomplete get :autocomplete
get :'with-depth', action: :with_depth get :'with-depth', action: :with_depth
get :versions, to: 'tag_versions#index' get :versions, to: 'tag_versions#index'
+5
ファイルの表示
@@ -0,0 +1,5 @@
class AddLinkedTagIdsToNicoTagVersions < ActiveRecord::Migration[8.0]
def change
add_column :nico_tag_versions, :linked_tag_ids, :text
end
end
+42
ファイルの表示
@@ -0,0 +1,42 @@
class MigrateLinkedTagsInNicoTagVersions < ActiveRecord::Migration[8.0]
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationNicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
def up
# 除票外部タグの恢復はないので除票履歴があれば全消しでよいはず.
execute <<~SQL
DELETE
t1
FROM
nico_tag_versions t1
INNER JOIN
nico_tag_versions t2
ON
t1.tag_id = t2.tag_id
AND t2.event_type = 'discard'
SQL
MigrationNicoTagVersion.find_each do |ntv|
linked_tag_ids = ntv.linked_tags.split.map { |name|
# 厳密には一意ぢゃないけどめんだぅだからこれでいいや.
MigrationTagVersion
.order(created_at: :desc, id: :desc)
.find_by("names ->> '$.ja.name' = ?", name)
&.tag_id
}.compact.uniq.sort.join(' ')
ntv.update_columns(linked_tag_ids:)
end
change_column_null :nico_tag_versions, :linked_tag_ids, false
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class RemoveLinkedTagsFromNicoTagVersions < ActiveRecord::Migration[8.0]
def change
remove_column :nico_tag_versions, :linked_tags
remove_column :nico_tag_versions, :name
end
end
生成ファイル
+2 -3
ファイルの表示
@@ -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_10_15_010000) do ActiveRecord::Schema[8.0].define(version: 2026_11_08_020000) 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
@@ -301,10 +301,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.string "event_type", null: false t.string "event_type", null: false
t.string "name", null: false
t.text "linked_tags", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.text "linked_tag_ids", null: false
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
+43 -8
ファイルの表示
@@ -11,28 +11,63 @@ RSpec.describe ExternalTag, type: :model do
expect(external.reload.name).to eq('raw tag[]') expect(external.reload.name).to eq('raw tag[]')
end end
it 'deletes post associations without deleting posts or version history' do it 'does not allow a persisted external tag to change its platform or name' do
external = create(:external_tag, name: 'immutable')
expect { external.update!(name: 'renamed') }
.to raise_error(ActiveRecord::RecordInvalid)
expect(external.errors[:name]).to include('は変更できません.')
external.reload
external.platform = nil
expect { external.save! }.to raise_error(ActiveRecord::RecordInvalid)
expect(external.errors[:platform]).to include('は変更できません.')
expect(external.reload).to have_attributes(platform: 'nico', name: 'immutable')
end
it 'rejects delete, which bypasses destroy callbacks' do
external = create(:external_tag)
expect { external.delete }.to raise_error(ActiveRecord::ReadOnlyRecord)
expect(external.reload).to be_persisted
end
it 'increments the counter through post associations without changing identity' do
external = create(:external_tag, name: 'counter')
post = create(:post)
expect {
PostExternalTag.create!(post:, external_tag: external)
}.to change { external.reload.post_count }.by(1)
expect(external.reload).to have_attributes(platform: 'nico', name: 'counter')
end
it 'does not destroy external tags or their post associations and history' do
external = create(:external_tag) external = create(:external_tag)
post = create(:post) post = create(:post)
PostExternalTag.create!(post:, external_tag: external) PostExternalTag.create!(post:, external_tag: external)
version = NicoTagVersionRecorder.record!( version = NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: nil) external_tag: external, event_type: :create, created_by_user: nil)
external.destroy! expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty expect(external.reload).to be_persisted
expect(post.reload.external_tags).to be_empty expect(PostExternalTag.exists?(post:, external_tag: external)).to be(true)
expect(post.reload.external_tags).to contain_exactly(external)
expect(version.reload.tag_id).to eq(external.id) expect(version.reload.tag_id).to eq(external.id)
end end
it 'deletes external links without deleting linked internal tags' do it 'does not destroy an external tag or its links to internal tags' do
external = create(:external_tag) external = create(:external_tag)
tag = create(:tag) tag = create(:tag)
NicoTagRelation.create!(nico_tag: external, tag:) relation = NicoTagRelation.create!(nico_tag: external, tag:)
external.destroy! expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty expect(external.reload).to be_persisted
expect(relation.reload).to be_persisted
expect(Tag.exists?(tag.id)).to be(true) expect(Tag.exists?(tag.id)).to be(true)
end end
end end
+3 -3
ファイルの表示
@@ -76,7 +76,7 @@ RSpec.describe 'Counts artifact policy and external references' do
end end
end end
it 'does not expose a private linked tag name through an external history snapshot' do it 'does not expose a private linked tag ID through an external history snapshot' do
tag = create(:tag, primary_name: 'private-linked-name') tag = create(:tag, primary_name: 'private-linked-name')
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name', TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024), category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
@@ -84,7 +84,7 @@ RSpec.describe 'Counts artifact policy and external references' do
auto_generated: false, aliases: [] } }) auto_generated: false, aliases: [] } })
external = create(:external_tag, name: 'public-external') external = create(:external_tag, name: 'public-external')
row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create', row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:public-external', linked_tags: 'private-linked-name', linked_tag_ids: tag.id.to_s,
created_at: Time.utc(2024)) created_at: Time.utc(2024))
post = create(:post) post = create(:post)
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create', snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
@@ -99,7 +99,7 @@ RSpec.describe 'Counts artifact policy and external references' do
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist
end end
public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update', public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update',
name: 'nico:public-external', linked_tags: 'private-linked-name-extra', linked_tag_ids: '',
created_at: Time.utc(2024, 7)) created_at: Time.utc(2024, 7))
HistoryAccess.set(user: nil) do HistoryAccess.set(user: nil) do
expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist
+3 -4
ファイルの表示
@@ -10,23 +10,22 @@ RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
expect(next_tag.id).to be > external.id expect(next_tag.id).to be > external.id
end end
it 'does not reuse physically deleted IDs in either table' do it 'does not reuse IDs after internal deletion while external tags remain' do
tag = create(:tag) tag = create(:tag)
external = create(:external_tag) external = create(:external_tag)
TagName.where(tag_id: tag.id).delete_all TagName.where(tag_id: tag.id).delete_all
tag.destroy! tag.destroy!
external.destroy! expect(ExternalTag.exists?(external.id)).to be(true)
expect(create(:tag).id).to be > external.id expect(create(:tag).id).to be > external.id
expect(create(:external_tag).id).to be > external.id expect(create(:external_tag).id).to be > external.id
end end
it 'retains existing overlapping IDs without renumbering on update or insert' do it 'retains overlapping IDs after a tag update and a new allocation' do
tag = create(:tag) tag = create(:tag)
external = create(:external_tag) external = create(:external_tag)
external.update_columns(id: tag.id) external.update_columns(id: tag.id)
tag.update!(category: :meta) tag.update!(category: :meta)
external.update!(name: 'updated_external')
before_tag = tag.reload.attributes before_tag = tag.reload.attributes
before_external = external.reload.attributes before_external = external.reload.attributes
+15 -5
ファイルの表示
@@ -47,24 +47,34 @@ RSpec.describe VersionRecord, type: :model do
external_tag: nico_tag, external_tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: "nico:#{ nico_tag.name }", linked_tag_ids: '',
linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
) )
expect { expect {
version.update!(name: 'nico:changed') version.update!(linked_tag_ids: '123')
}.to raise_error(ActiveRecord::ReadOnlyRecord) }.to raise_error(ActiveRecord::ReadOnlyRecord)
end end
it 'rejects discard and restore versions for external tags' do
[:discard, :restore].each do |event_type|
version = NicoTagVersion.new(
external_tag: nico_tag, version_no: 1, event_type:,
linked_tag_ids: '', created_at: Time.current)
expect(version).not_to be_valid
expect(version.errors[:event_type]).not_to be_empty
end
end
it 'prevents NicoTagVersion destroy' do it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!( version = NicoTagVersion.create!(
external_tag: nico_tag, external_tag: nico_tag,
version_no: 1, version_no: 1,
event_type: 'create', event_type: 'create',
name: "nico:#{ nico_tag.name }", linked_tag_ids: '',
linked_tags: '',
created_at: Time.current, created_at: Time.current,
created_by_user: nil created_by_user: nil
) )
+114 -6
ファイルの表示
@@ -19,7 +19,7 @@ RSpec.describe 'Counts table API', type: :request do
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } }, names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2023)) created_at: Time.utc(2023))
NicoTagVersion.create!(external_tag: external, version_no: 1, NicoTagVersion.create!(external_tag: external, version_no: 1,
event_type: 'create', name: 'beta', linked_tags: '', created_at: Time.utc(2023)) event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
snapshots = [ snapshots = [
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] }, { tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
{ external_tag_id: external.id }] { external_tag_id: external.id }]
@@ -50,6 +50,115 @@ RSpec.describe 'Counts table API', type: :request do
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') } def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
context 'nico link ID snapshots through the V1 field name' do
let(:base) do
super().merge(resource: 'nico_tags')
end
before do
NicoTagVersion.create!(external_tag: external, version_no: 2,
event_type: 'update', linked_tag_ids: tag.id.to_s,
created_at: Time.utc(2024, 1, 1))
NicoTagVersion.create!(external_tag: external, version_no: 3,
event_type: 'update', linked_tag_ids: '',
created_at: Time.utc(2024, 2, 1))
end
it 'returns ID values under linked_tags without exposing the physical column key' do
result = query
expect(versions(result)).to eq([1, 2, 3])
expect(result['versions'].map { _1.fetch('linked_tags') }).to eq(['', tag.id.to_s, ''])
result['versions'].each do |row|
expect(row).not_to have_key('linked_tag_ids')
expect(row.fetch('name')).to eq('nico:beta')
end
end
it 'preloads external names separately for aliased link-change details' do
statements = []
listener = lambda do |_name, _start, _finish, _id, payload|
statements << payload[:sql]
end
ActiveSupport::Notifications.subscribed(listener, 'sql.active_record') do
result = query(axis('changed_field', 'linked_tags'))
expect(versions(result)).to eq([2, 3])
expect(result.fetch('versions').map { _1.fetch('name') })
.to eq(['nico:beta', 'nico:beta'])
end
details = statements.find { _1.include?('AS counts_latest') }
expect(details).not_to be_nil
expect(details).not_to include('JOIN `external_tags`')
names = statements.select { _1.match?(/SELECT .* FROM `external_tags`/) }
expect(names.length).to eq(1)
end
it 'advertises the legacy field, label and default columns only' do
get '/counts/definitions/nico_tags', params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
['fields', 'filter_fields', 'output_fields'].each do |collection|
keys = body.fetch(collection).map { _1.fetch('key') }
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
field = body.fetch('output_fields').find { _1['key'] == 'linked_tags' }
expect(field.fetch('label')).to eq('counts:attributes.linked_tags')
body.fetch('axes').each_value do |keys|
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
body.fetch('defaults').select { _1['mode'] == 'details' }.each do |entry|
settings = entry.fetch('params').to_h { [_1.fetch('key'), _1.fetch('value')] }
expect(settings.fetch('columns').split).to include('name', 'linked_tags')
expect(settings.fetch('columns').split).not_to include('linked_tag_ids')
end
end
it 'filters IDs and legacy drills and compares link changes through linked_tags' do
predicate = { conditions: '', 'conditions.0.field' => 'linked_tags',
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
expect(versions(query(predicate))).to eq([2])
expect(versions(query(axis('linked_tags', tag.id.to_s, root: 'drill')))).to eq([2])
expect(versions(query(axis('changed_field', 'linked_tags')))).to eq([2, 3])
expect(versions(query(predicate.merge('conditions.0.value' => 'alpha')))).to be_empty
end
it 'sorts ID snapshots through the public field in both directions' do
expect(versions(query(sort: 'linked_tags:asc version_no:asc'))).to eq([1, 3, 2])
expect(versions(query(sort: 'linked_tags:desc version_no:asc'))).to eq([2, 1, 3])
end
it 'restores historical link IDs before applying a latest snapshot filter' do
predicate = { definition: 'latest', at: '2024-01-15T00:00:00Z',
conditions: '', 'conditions.0.field' => 'linked_tags',
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
expect(versions(query(predicate))).to eq([2])
expect(versions(query(predicate.except(:at)))).to be_empty
end
it 'groups by linked_tags ID values and accepts the resulting drill condition' do
sign_in_as(create(:user, :admin))
result = query(mode: 'summary', rows: 'linked_tags', cols: '', sort: '')
expect(result.fetch('cells')).to match_array([
{ 'values' => [''], 'count' => 2 },
{ 'values' => [tag.id.to_s], 'count' => 1 }])
expect(versions(query(axis('linked_tags', tag.id.to_s)))).to eq([2])
end
it 'rejects the physical column as a public filter, axis, sort or change field' do
sign_in_as(create(:user, :admin))
[
axis('linked_tag_ids', tag.id.to_s),
axis('changed_field', 'linked_tag_ids'),
{ mode: 'summary', rows: 'linked_tag_ids', cols: '', sort: '' },
{ sort: 'linked_tag_ids:asc' }].each do |invalid|
get '/counts/query', params: base.merge(invalid)
expect(response).to have_http_status(:bad_request), invalid.inspect
end
end
end
it 'identifies a totals timeout without exposing SQL in the error response' do it 'identifies a totals timeout without exposing SQL in the error response' do
allow_any_instance_of(CountsPivotTotals).to receive(:call) allow_any_instance_of(CountsPivotTotals).to receive(:call)
.and_raise(CountsPivotTotals::TimedOut, 'private SQL') .and_raise(CountsPivotTotals::TimedOut, 'private SQL')
@@ -223,11 +332,10 @@ RSpec.describe 'Counts table API', type: :request do
expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4]) expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4])
end end
it 'resolves external names from current registrations too' do it 'resolves immutable external names across historical snapshots' do
external.update!(name: 'renamed-beta') expect(versions(query(tags: 'nico:beta'))).to eq([1])
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([1]) expect(versions(query(tags: 'nico:renamed-beta'))).to eq([])
expect(versions(query(tags: 'nico:beta'))).to eq([]) expect(versions(query(tags: 'not:nico:beta'))).to eq([2, 3, 4])
expect(versions(query(tags: 'not:nico:renamed-beta'))).to eq([2, 3, 4])
end end
end end
+2 -2
ファイルの表示
@@ -20,7 +20,7 @@ RSpec.describe 'error responses', type: :request do
patch "/tags/#{ tag.id }", params: { category: 'nico' } patch "/tags/#{ tag.id }", params: { category: 'nico' }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
expect(json).to include( expect(json).to include(
'type' => 'validation_error', 'type' => 'validation_error',
'message' => '入力内容を確認してください.', 'message' => '入力内容を確認してください.',
@@ -37,7 +37,7 @@ RSpec.describe 'error responses', type: :request do
put "/users/#{ user.id }", params: { name: 'a' * 256 } put "/users/#{ user.id }", params: { name: 'a' * 256 }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_content)
expect(json).to include( expect(json).to include(
'type' => 'validation_error', 'type' => 'validation_error',
'message' => '入力内容を確認してください.') 'message' => '入力内容を確認してください.')
+8 -14
ファイルの表示
@@ -38,12 +38,9 @@ RSpec.describe 'NicoTags', type: :request do
end end
it 'filters by nico tag name, linked tag name, and link status' do it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:external_tag) linked = create(:external_tag, name: 'search_linked')
linked.update!(name: 'search_linked') unlinked = create(:external_tag, name: 'search_unlinked')
unlinked = create(:external_tag) other = create(:external_tag, name: 'other')
unlinked.update!(name: 'search_unlinked')
other = create(:external_tag)
other.update!(name: 'other')
destination = create(:tag, category: :general) destination = create(:tag, category: :general)
primary_tag_name_for(destination, 'ja').update!(name: 'destination_search') primary_tag_name_for(destination, 'ja').update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination) NicoTagRelation.create!(nico_tag: linked, tag: destination)
@@ -80,11 +77,9 @@ RSpec.describe 'NicoTags', type: :request do
end end
it 'sorts by name and timestamps' do it 'sorts by name and timestamps' do
older = create(:external_tag) older = create(:external_tag, name: 'ordered_a')
older.update!(name: 'ordered_a')
older.update_columns(created_at: 2.days.ago) older.update_columns(created_at: 2.days.ago)
newer = create(:external_tag) newer = create(:external_tag, name: 'ordered_b')
newer.update!(name: 'ordered_b')
newer.update_columns(created_at: 1.day.ago) newer.update_columns(created_at: 1.day.ago)
older_post_tag = older_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: older) PostExternalTag.create!(post: create(:post), external_tag: older)
@@ -189,9 +184,8 @@ RSpec.describe 'NicoTags', type: :request do
versions = nico_tag.reload.nico_tag_versions.order(:version_no) versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update']) expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([ expect(versions.last.linked_tag_ids.split.map(&:to_i))
'nico_linked_a', .to match_array([linked_a.id, linked_b.id])
'nico_linked_b'])
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end end
@@ -210,7 +204,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(json).to eq([]) expect(json).to eq([])
expect(nico_tag.reload.linked_tags).to be_empty expect(nico_tag.reload.linked_tags).to be_empty
expect(nico_tag.nico_tag_versions.order(:version_no).last) expect(nico_tag.nico_tag_versions.order(:version_no).last)
.to have_attributes(linked_tags: '', created_by_user: member) .to have_attributes(linked_tag_ids: '', created_by_user: member)
end end
it 'returns the tags field error when a nico tag is specified directly' do it 'returns the tags field error when a nico tag is specified directly' do
+1 -1
ファイルの表示
@@ -20,7 +20,7 @@ RSpec.describe 'Post history privacy', type: :request do
created_at: origin + index.hours) created_at: origin + index.hours)
end end
NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create', NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:external-history-tag', linked_tags: 'private-history-tag', linked_tag_ids: tag.id.to_s,
created_at: origin) created_at: origin)
['create', 'discard', 'restore', 'update'].each_with_index do |event, index| ['create', 'discard', 'restore', 'update'].each_with_index do |event, index|
snapshots = snapshots =
+49
ファイルの表示
@@ -22,6 +22,55 @@ RSpec.describe 'Counts export contracts' do
.and_return(@counts_export_dir) .and_return(@counts_export_dir)
end end
context 'nico link IDs with the V1 export field name' do
let(:tag) { create(:tag, primary_name: 'linked export tag') }
let(:external) { create(:external_tag, name: 'external export tag') }
let(:payload) do
super().merge('resource' => 'nico_tags', 'columns' => 'linked_tags',
'headers' => '["linked_tags"]')
end
before do
NicoTagVersion.create!(external_tag: external, version_no: 1,
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
NicoTagVersion.create!(external_tag: external, version_no: 2,
event_type: 'update', linked_tag_ids: tag.id.to_s, created_at: Time.utc(2024))
end
it 'exports recorded IDs with the legacy JSON key and metadata' do
result = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
expect(result.fetch('data')).to eq([
{ 'linked_tags' => '' }, { 'linked_tags' => tag.id.to_s }])
expect(result.fetch('metadata').fetch('columns')).to eq(['linked_tags'])
keys = result.fetch('metadata').fetch('fields').map { _1.fetch('key') }
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
it 'exports the selected legacy CSV header and ID values' do
body = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
expect(CSV.parse(body.delete_prefix("\uFEFF")))
.to eq([['linked_tags'], [''], [tag.id.to_s]])
end
it 'keeps the legacy link column in default JSON exports' do
options = payload.except('columns', 'headers')
result = JSON.parse(CountsTableExport.new(options).body.to_a.join)
expect(result.fetch('metadata').fetch('columns')).to include('linked_tags')
result.fetch('data').each do |row|
expect(row).to have_key('linked_tags')
expect(row).not_to have_key('linked_tag_ids')
end
end
it 'rejects the physical column as a selected CSV or JSON field' do
['csv', 'json'].each do |format|
options = payload.merge('format' => format, 'columns' => 'linked_tag_ids')
expect { CountsTableExport.new(options) }.to raise_error(ArgumentError)
end
end
end
context 'serialization after the authorised query' do context 'serialization after the authorised query' do
let(:rows) { [{ title: "=formula\n\"quoted\"" }, { title: 'second' }] } let(:rows) { [{ title: "=formula\n\"quoted\"" }, { title: 'second' }] }
let(:page) do let(:page) do
+18 -8
ファイルの表示
@@ -8,21 +8,23 @@ RSpec.describe NicoTagVersionRecorder do
described_class.record!(external_tag:, event_type:, created_by_user: member) described_class.record!(external_tag:, event_type:, created_by_user: member)
end end
it 'records the external association and platform-qualified name' do it 'records the external association and derives the platform-qualified name' do
version = record(:create) version = record(:create)
expect(version).to have_attributes( expect(version).to have_attributes(
external_tag:, version_no: 1, event_type: 'create', external_tag:, version_no: 1, event_type: 'create',
name: 'nico:raw tag[]', linked_tags: '', created_by_user: member) name: 'nico:raw tag[]', linked_tag_ids: '', created_by_user: member)
expect(external_tag.reload.nico_tag_versions).to contain_exactly(version) expect(external_tag.reload.nico_tag_versions).to contain_exactly(version)
end end
it 'uses the latest history number when the record has no version_no column' do it 'uses the latest history number when the record has no version_no column' do
first = record(:create) first = record(:create)
external_tag.update!(name: 'changed') tag = create(:tag)
NicoTagRelation.create!(nico_tag: external_tag, tag:)
second = record(:update) second = record(:update)
expect(second).to have_attributes(version_no: 2, name: 'nico:changed') expect(second).to have_attributes(version_no: 2, name: 'nico:raw tag[]',
linked_tag_ids: tag.id.to_s)
expect(first.reload.name).to eq('nico:raw tag[]') expect(first.reload.name).to eq('nico:raw tag[]')
expect(external_tag.reload.has_attribute?(:version_no)).to be(false) expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
end end
@@ -40,7 +42,7 @@ RSpec.describe NicoTagVersionRecorder do
expect(version).to have_attributes( expect(version).to have_attributes(
external_tag: external, name: 'registered_external:foo', version_no: 1, external_tag: external, name: 'registered_external:foo', version_no: 1,
event_type: 'create', linked_tags: '', created_by_user: member) event_type: 'create', linked_tag_ids: '', created_by_user: member)
end end
it 'returns the latest version without appending an unchanged snapshot' do it 'returns the latest version without appending an unchanged snapshot' do
@@ -64,13 +66,21 @@ RSpec.describe NicoTagVersionRecorder do
expect(external_tag.nico_tag_versions).to contain_exactly(first) expect(external_tag.nico_tag_versions).to contain_exactly(first)
end end
it 'records sorted linked internal names and later link removal' do
it 'rejects discard and restore events for external tags' do
[:discard, :restore].each do |event_type|
expect { record(event_type) }
.to raise_error(ArgumentError, "Invalid event_type: #{ event_type }")
end
end
it 'records sorted linked internal IDs and later link removal' do
tags = ['z_link', 'a_link'].map { |name| create(:tag, primary_name: name, category: :general) } tags = ['z_link', 'a_link'].map { |name| create(:tag, primary_name: name, category: :general) }
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) } tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
expect(record(:create).linked_tags).to eq('a_link z_link') expect(record(:create).linked_tag_ids).to eq(tags.map(&:id).sort.join(' '))
external_tag.linked_tags = [tags.first] external_tag.linked_tags = [tags.first]
expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link') expect(record(:update)).to have_attributes(version_no: 2, linked_tag_ids: tags.first.id.to_s)
end end
end end
+3366
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読み込み
+12 -4
ファイルの表示
@@ -158,7 +158,15 @@ private ID の正誤に応じて許可可否を変えないため、条件の一
非公開列を含む列順変更は API の column_values_editable でも不可と通知する。 非公開列を含む列順変更は API の column_values_editable でも不可と通知する。
公開列の値を使うソートは引き続き利用できる。 公開列の値を使うソートは引き続き利用できる。
明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。 明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。
外部タグ履歴の linked_tags は名前しか記録していないため、非公開タグの記録名を含む 外部タグ履歴の linked_tag_ids は、連携する内部タグの ID を半角スペース区切りで
スナップショットを保守的に非公開とする。名前全体を空白区切りの境界で照合する。 記録する。除票履歴のある内部タグの ID を含むスナップショットを非管理者へ公開しない。
区切りが曖昧な旧データでは過剰な秘匿の可能性はあるが、参照 ID がない旧形式から 名前や UI ロカールを参照 ID の判定に使わない。
名前の公開可否を楽観的に推定しない。 外部タグ名は履歴に保存せず、external_tags の platform と name を参照する。
V1 Counts の公開フィールド名は linked_tags を維持し、DB の linked_tag_ids へ対応付ける。
明細・フィルタ・集計軸・変更項目・ソート・CSV / JSON 出力で同じ対応付けを使う。
既定列と既存の表示ラベルも linked_tags のままとし、値は ID のスペース区切りとする。
linked_tag_ids は物理カラム名であり、Counts の公開フィールドとしては受け付けない。
旧 URL や保存済み設定のフィールド指定は維持できるが、値にタグ名を指定した検索条件や
ドリル条件は ID 値への見直しが必要となる。旧履歴の名称は移行後の ID 列から復元できず、
現在の名称からの推定変換も行わない。
+1 -6
ファイルの表示
@@ -166,12 +166,7 @@ export const pageRoutes = (
robots: 'noindex', authentication: 'user' } }, robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/>, { path: ':id', element: <MaterialDetailPage/>,
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] }, handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
{ path: 'wiki', element: <LocaleRedirect to="/tags"/>, { path: 'wiki', element: <WikiSearchPage/>,
handle: { redirectTo: '/tags' } },
{ path: 'tag-descriptions', element: <LocaleRedirect to="/tags"/>,
handle: { redirectTo: '/tags' } },
{ path: 'tag-groups',
element: <WikiSearchPage key="tag_group" resourceType="tag_group"/>,
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } }, handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
{ path: 'wiki/:title', element: <WikiDetailPage/>, { path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale, handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
+14 -28
ファイルの表示
@@ -29,7 +29,7 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
import type { AvailableLocale, LocaleCode } from '@/lib/locales' import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User, WikiPage } from '@/types' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { type Props = {
user: User | null user: User | null
@@ -39,11 +39,10 @@ type NavDensity = 'auto' | 'comfortable' | 'compact'
export const menuOutline = ( export const menuOutline = (
{ tag, material, wikiId, wikiResourceType, user, pathName: fullPathname }: { { tag, material, wikiId, user, pathName: fullPathname }: {
tag?: Tag | null tag?: Tag | null
material?: Material | null material?: Material | null
wikiId: number | null wikiId: number | null
wikiResourceType?: WikiPage['resourceType']
user: User | null user: User | null
pathName: string }, pathName: string },
): Menu => { ): Menu => {
@@ -100,29 +99,17 @@ export const menuOutline = (
visible: materialFlg && Boolean (material?.tag) }, visible: materialFlg && Boolean (material?.tag) },
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`, { ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] }, visible: materialFlg }] },
...([ { ...message ('wiki'), to: '/wiki', subMenu: [
{ key: 'tagGroups', path: '/tag-groups', type: 'tag_group' }] as const) { ...message ('search'), to: '/wiki' },
.map (({ key, path, type }): MenuVisibleItem => { { ...message ('create'), to: '/wiki/new', visible: editable },
const current = (wikiResourceType ?? 'tag_description') === type { ...message ('allHistory'), to: '/wiki/changes' },
const detailFlg = wikiPageFlg && current { ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
const creationPath = { component: <Separator/>, visible: wikiPageFlg },
type === 'tag_group' { ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
? `/wiki/new?title=${ encodeURIComponent ('タググループ:') }` : '/wiki/new' visible: wikiPageFlg },
return { ...message (key), to: path, { ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
base: (pathName.startsWith ('/wiki') && current) ? '/wiki' : path, { ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
subMenu: [ visible: wikiPageFlg && editable }] },
{ ...message ('search'), to: path },
{ ...message ('create'), to: creationPath, visible: editable },
{ ...message ('allHistory'), to: '/wiki/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: detailFlg },
{ ...message ('postsWithCount', { count: postCount }),
to: `/posts?tags=${ wikiTitle }`, visible: detailFlg },
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`,
visible: detailFlg },
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: detailFlg && editable }] }
}),
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [ { ...message ('help'), to: '/help/home', base: '/help', subMenu: [
{ ...message ('search'), to: '/help' }, { ...message ('search'), to: '/help' },
{ ...message ('allHistory'), to: '/help/changes' }, { ...message ('allHistory'), to: '/help/changes' },
@@ -242,8 +229,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
queryKey: materialsKeys.show (effectiveTitle, locale), queryKey: materialsKeys.show (effectiveTitle, locale),
queryFn: () => fetchMaterial (effectiveTitle, locale) }) queryFn: () => fetchMaterial (effectiveTitle, locale) })
const menu = menuOutline ({ tag, material, wikiId, const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
wikiResourceType: wikiPage?.resourceType, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000') const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
const moreMenu = menu.filter (item => const moreMenu = menu.filter (item =>
+3 -5
ファイルの表示
@@ -100,11 +100,10 @@ const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => { const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
const title = url.searchParams.get ('title') ?? '' const title = url.searchParams.get ('title') ?? ''
const resourceType = 'tag_group'
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ title, resourceType }, locale), queryKey: wikiKeys.index ({ title }, locale),
queryFn: () => fetchWikiPages ({ title, resourceType }, locale) }) queryFn: () => fetchWikiPages ({ title }, locale) })
} }
@@ -311,8 +310,7 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
&& Boolean (mPost (u.pathname))), && Boolean (mPost (u.pathname))),
run: prefetchPostShow }, run: prefetchPostShow },
{ test: u => u.pathname === '/posts/changes', run: prefetchPostChanges }, { test: u => u.pathname === '/posts/changes', run: prefetchPostChanges },
{ test: u => u.pathname === '/tag-groups', { test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex },
run: prefetchWikiPagesIndex },
{ test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname))
&& Boolean (mWiki (u.pathname))), && Boolean (mWiki (u.pathname))),
run: prefetchWikiPageShow }, run: prefetchWikiPageShow },
+1 -2
ファイルの表示
@@ -5,7 +5,6 @@ import type {
FetchPostsParams, FetchPostsParams,
FetchTagsParams, FetchTagsParams,
MaterialFilter, MaterialFilter,
WikiPage,
WikiTagReference } from '@/types' WikiTagReference } from '@/types'
export const localesKeys = { export const localesKeys = {
@@ -131,7 +130,7 @@ export const wikiKeys = {
autolinkCandidates: (language: string, locale: LocaleCode) => autolinkCandidates: (language: string, locale: LocaleCode) =>
['wiki', 'autolink', locale, language] as const, ['wiki', 'autolink', locale, language] as const,
index: ( index: (
p: { title?: string, resourceType?: WikiPage['resourceType'] }, p: { title?: string },
locale: LocaleCode = 'ja', locale: LocaleCode = 'ja',
) => ['wiki', locale, 'index', p] as const, ) => ['wiki', locale, 'index', p] as const,
show: ( show: (
+12 -56
ファイルの表示
@@ -1,6 +1,6 @@
import { isAxiosError } from 'axios' import { isAxiosError } from 'axios'
import { apiGet, apiPost, apiPut } from '@/lib/api' import { apiGet } from '@/lib/api'
import { switchLocalePath } from '@/lib/locales' import { switchLocalePath } from '@/lib/locales'
import { fetchTag, fetchTagByName } from '@/lib/tags' import { fetchTag, fetchTagByName } from '@/lib/tags'
@@ -24,54 +24,19 @@ export const fetchWikiChanges = (
export const fetchWikiPages = async ( export const fetchWikiPages = async (
{ title, resourceType = 'tag_description' }: { { title }: { title?: string },
title?: string, resourceType?: WikiPage['resourceType'] },
locale: LocaleCode = 'ja', locale: LocaleCode = 'ja',
): Promise<WikiPage[]> => apiGet ( ): Promise<WikiPage[]> =>
resourceType === 'tag_group' ? '/tag_groups' : '/tags/descriptions', await apiGet ('/wiki', { locale, params: { title } })
{ locale, params: { title, document: '1' } })
export const fetchWikiPage = async ( export const fetchWikiPage = async (
id: string, id: string,
{ version }: { version?: string }, { version }: { version?: string },
locale: LocaleCode = 'ja', locale: LocaleCode = 'ja',
): Promise<WikiPage> => { ): Promise<WikiPage> =>
if (version != null) await apiGet (`/wiki/${ encodeURIComponent (id) }`, {
return apiGet (`/wiki/${encodeURIComponent (id)}`, { locale, params: { version } }) locale, params: version ? { version } : { } })
try
{
return await apiGet (`/tags/descriptions/${encodeURIComponent (id)}`,
{ locale, params: { document: '1' } })
}
catch (error)
{
if (!isAxiosError (error) || error.response?.status !== 404)
throw error
return apiGet (`/tag_groups/${encodeURIComponent (id)}`,
{ locale, params: { document: '1' } })
}
}
export const saveTagDocument = async (
{ title, body, page }: { title: string, body: string, page?: WikiPage },
locale: LocaleCode,
): Promise<WikiPage> => {
const groupName = /^(?:タググループ:|tag_group:)(.*)$/i.exec (title.trim ())?.[1]
const options = { locale, params: { document: '1' } }
if (page?.resourceType === 'tag_group' || (page == null && groupName != null))
{
const attributes = { name: groupName ?? title.trim (), body }
return page == null
? apiPost ('/tag_groups', attributes, options)
: apiPut (`/tag_groups/${encodeURIComponent (String (page.id))}`, attributes, options)
}
if (page == null)
return apiPost ('/tags/descriptions', { title, body }, options)
return apiPut (`/tags/${encodeURIComponent (String (page.tagId))}/description`,
{ title, body }, options)
}
export const fetchWikiPageByTitle = async ( export const fetchWikiPageByTitle = async (
@@ -81,14 +46,7 @@ export const fetchWikiPageByTitle = async (
): Promise<WikiPage | null> => { ): Promise<WikiPage | null> => {
try try
{ {
if (version != null) return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { locale, params: { version } })
return await apiGet (`/wiki/title/${encodeURIComponent (title)}`,
{ locale, params: { version } })
const groupName = /^(?:タググループ:|tag_group:)(.*)$/i.exec (title.trim ())?.[1]
const path = groupName == null
? `/tags/name/${encodeURIComponent (title)}/description`
: `/tag_groups/name/${encodeURIComponent (groupName)}`
return await apiGet (path, { locale, params: { document: '1' } })
} }
catch (error) catch (error)
{ {
@@ -114,7 +72,7 @@ export const fetchWikiPageByReference = async (
{ {
if (!isAxiosError (error) || error.response?.status !== 404) if (!isAxiosError (error) || error.response?.status !== 404)
throw error throw error
const tag = await fetchTagByName (reference, locale) const tag = await fetchWikiTag ({ wikiId: reference }, locale)
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale) return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
} }
} }
@@ -128,9 +86,7 @@ export const fetchWikiTag = async (
return fetchTagByName (reference.name, locale) return fetchTagByName (reference.name, locale)
try try
{ {
const page = await fetchWikiPage (reference.wikiId, { }, locale) return await apiGet (`/wiki/${encodeURIComponent (reference.wikiId)}/tag`, { locale })
return (page.resourceType === 'tag_description' && page.tagId != null)
? fetchTag (String (page.tagId), locale) : null
} }
catch (error) catch (error)
{ {
-2
ファイルの表示
@@ -14,8 +14,6 @@
"materials": "チカㇻペ", "materials": "チカㇻペ",
"suppressions": "アエアイカㇷ゚テㇷ゚", "suppressions": "アエアイカㇷ゚テㇷ゚",
"wiki": "Wiki", "wiki": "Wiki",
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
"tagGroups": "$t(counts:resources.tag_groups)",
"create": "アシㇼ", "create": "アシㇼ",
"edit": "イヌイェㇷ゚", "edit": "イヌイェㇷ゚",
"entertainment": "シノッ", "entertainment": "シノッ",
-2
ファイルの表示
@@ -13,8 +13,6 @@
"materials": "素材", "materials": "素材",
"suppressions": "抑止", "suppressions": "抑止",
"wiki": "Wiki", "wiki": "Wiki",
"tagDescriptions": "$t(counts:resources.tag_descriptions)",
"tagGroups": "$t(counts:resources.tag_groups)",
"create": "新規", "create": "新規",
"edit": "編輯", "edit": "編輯",
"entertainment": "おたのしみ", "entertainment": "おたのしみ",
+1 -1
ファイルの表示
@@ -135,7 +135,7 @@ const MaterialDetailPage: FC = () => {
<TabGroup> <TabGroup>
{material.tag && ( {material.tag && (
<Tab name={label ('navigation:tagDescriptions')}> <Tab name={label ('navigation:wiki')}>
<WikiBody <WikiBody
title={material.tag.name} title={material.tag.name}
language={localeLanguage (locale)} language={localeLanguage (locale)}
+1 -1
ファイルの表示
@@ -176,7 +176,7 @@ const PostListPage: FC = () => {
{loading && label ('common:loading')} {loading && label ('common:loading')}
</Tab> </Tab>
{tags.length === 1 && ( {tags.length === 1 && (
<Tab name={label ('navigation:tagDescriptions')}> <Tab name={label ('navigation:wiki')}>
<WikiBody title={tags[0]} body={wikiPage?.body} <WikiBody title={tags[0]} body={wikiPage?.body}
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description' language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
? localeLanguage (locale) : 'und')}/> ? localeLanguage (locale) : 'und')}/>
+1 -2
ファイルの表示
@@ -51,8 +51,7 @@ const WikiDetailPage: FC = () => {
queryKey: helpKeys.titles ('ja'), queryKey: helpKeys.titles ('ja'),
queryFn: () => getHelpTitleIndex ('ja'), queryFn: () => getHelpTitleIndex ('ja'),
staleTime: Infinity }) staleTime: Infinity })
const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase () const lookupTitle = title.trim ().normalize ('NFKC').toLowerCase ().replace (/^ヘルプ:/, '')
.replace (/^(?:ヘルプ|手引):/, '')
const identifiers = useMemo (() => ( const identifiers = useMemo (() => (
(titleIndex != null && Object.hasOwn (titleIndex, lookupTitle)) (titleIndex != null && Object.hasOwn (titleIndex, lookupTitle))
? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle]) ? titleIndex[lookupTitle] : []), [titleIndex, lookupTitle])
+13 -10
ファイルの表示
@@ -11,8 +11,8 @@ import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import PageHead from '@/components/common/PageHead' import PageHead from '@/components/common/PageHead'
import { fetchWikiPage, saveTagDocument } from '@/lib/wiki' import { apiGet, apiPut } from '@/lib/api'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys' import { wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -33,7 +33,7 @@ const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
type Props = { user: User | null } type Props = { user: User | null }
type WikiFormField = 'title' | 'name' | 'body' type WikiFormField = 'title' | 'body'
const WikiEditPage: FC<Props> = ({ user }) => { const WikiEditPage: FC<Props> = ({ user }) => {
@@ -50,7 +50,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
const source = `${locale}:${id}` const source = `${locale}:${id}`
const { data: wiki, isPending: loading } = useQuery ({ const { data: wiki, isPending: loading } = useQuery ({
queryKey: wikiKeys.show (id ?? '', { }, locale), queryKey: wikiKeys.show (id ?? '', { }, locale),
queryFn: () => fetchWikiPage (id ?? '', { }, locale), queryFn: () => apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, { locale }),
enabled: editable }) enabled: editable })
const [body, setBody] = useState (wiki?.body ?? '') const [body, setBody] = useState (wiki?.body ?? '')
const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source) const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source)
@@ -60,19 +60,22 @@ const WikiEditPage: FC<Props> = ({ user }) => {
useValidationErrors<WikiFormField> () useValidationErrors<WikiFormField> ()
const handleSubmit = async () => { const handleSubmit = async () => {
if (pending || !editable || wiki == null) if (pending || !editable)
return return
clearValidationErrors () clearValidationErrors ()
const formData = new FormData ()
formData.append ('title', title)
formData.append ('body', body)
try try
{ {
await saveTagDocument ({ title, body, page: wiki }, locale) await apiPut (`/wiki/${ id }`, formData,
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
qc.setQueryData (wikiKeys.show (title, { }, locale), qc.setQueryData (wikiKeys.show (title, { }, locale),
(prev: WikiPage | undefined) => (prev: WikiPage | undefined) =>
prev == null ? prev : { ...prev, title, body }) prev == null ? prev : { ...prev, title, body })
await Promise.all ([ qc.invalidateQueries ({ queryKey: wikiKeys.root })
qc.invalidateQueries ({ queryKey: wikiKeys.root }),
qc.invalidateQueries ({ queryKey: tagsKeys.root })])
toast ({ title: label ('shared:messages.postSuccess') }) toast ({ title: label ('shared:messages.postSuccess') })
navigate (`/wiki/${ encodeURIComponent (title) }`) navigate (`/wiki/${ encodeURIComponent (title) }`)
} }
@@ -107,7 +110,7 @@ const WikiEditPage: FC<Props> = ({ user }) => {
{/* タイトル */} {/* タイトル */}
{/* TODO: タグ補完 */} {/* TODO: タグ補完 */}
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}> <FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input type="text" <input type="text"
value={title} value={title}
+10 -5
ファイルの表示
@@ -12,7 +12,7 @@ import FormField from '@/components/common/FormField'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import PageHead from '@/components/common/PageHead' import PageHead from '@/components/common/PageHead'
import { saveTagDocument } from '@/lib/wiki' import { apiPost } from '@/lib/api'
import { tagsKeys, wikiKeys } from '@/lib/queryKeys' import { tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
@@ -21,7 +21,7 @@ import Forbidden from '@/pages/Forbidden'
import type { FC } from 'react' import type { FC } from 'react'
import type { User } from '@/types' import type { User, WikiPage } from '@/types'
const mdParser = new MarkdownIt const mdParser = new MarkdownIt
const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
@@ -32,7 +32,7 @@ const FIXED_WIKI_EDITOR_VIEW = { menu: true, md: true, html: true } as const
type Props = { user: User | null } type Props = { user: User | null }
type WikiFormField = 'title' | 'name' | 'body' type WikiFormField = 'title' | 'body'
const WikiNewPage: FC<Props> = ({ user }) => { const WikiNewPage: FC<Props> = ({ user }) => {
@@ -55,9 +55,14 @@ const WikiNewPage: FC<Props> = ({ user }) => {
const handleSubmit = async () => { const handleSubmit = async () => {
clearValidationErrors () clearValidationErrors ()
const formData = new FormData
formData.append ('title', title)
formData.append ('body', body)
try try
{ {
const data = await saveTagDocument ({ title, body }, locale) const data = await apiPost<WikiPage> ('/wiki', formData,
{ locale, headers: { 'Content-Type': 'multipart/form-data' } })
await qc.cancelQueries ({ queryKey: wikiKeys.root }) await qc.cancelQueries ({ queryKey: wikiKeys.root })
qc.setQueryData (wikiKeys.show (data.title, { }, locale), data) qc.setQueryData (wikiKeys.show (data.title, { }, locale), data)
await Promise.all ([ await Promise.all ([
@@ -86,7 +91,7 @@ const WikiNewPage: FC<Props> = ({ user }) => {
{/* タイトル */} {/* タイトル */}
{/* TODO: タグ補完 */} {/* TODO: タグ補完 */}
<FormField label={label ('shared:labels.title')} messages={fieldErrors.title ?? fieldErrors.name}> <FormField label={label ('shared:labels.title')} messages={fieldErrors.title}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input type="text" <input type="text"
value={title} value={title}
+7 -11
ファイルの表示
@@ -8,7 +8,7 @@ import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead' import PageHead from '@/components/common/PageHead'
import { fetchWikiPages } from '@/lib/wiki' import { apiGet } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys' import { wikiKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils' import { dateString, inputClass } from '@/lib/utils'
@@ -17,20 +17,16 @@ import type { FormEvent , FC } from 'react'
import type { WikiPage } from '@/types' import type { WikiPage } from '@/types'
const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = ( const WikiSearchPage: FC = () => {
{ resourceType = 'tag_description' },
) => {
const { message, label } = useUITranslation ('shared') const { message, label } = useUITranslation ('shared')
const locale = useLocale () const locale = useLocale ()
const heading =
resourceType === 'tag_group'
? 'navigation:tagGroups' : 'navigation:tagDescriptions'
const [title, setTitle] = useState ('') const [title, setTitle] = useState ('')
const [text, setText] = useState ('') const [text, setText] = useState ('')
const [submittedTitle, setSubmittedTitle] = useState ('') const [submittedTitle, setSubmittedTitle] = useState ('')
const { data: results = [], refetch } = useQuery ({ const { data: results = [], refetch } = useQuery ({
queryKey: wikiKeys.index ({ title: submittedTitle, resourceType }, locale), queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
queryFn: () => fetchWikiPages ({ title: submittedTitle, resourceType }, locale) }) queryFn: () => apiGet<WikiPage[]> ('/wiki', {
locale, params: { title: submittedTitle } }) })
const handleSearch = (ev: FormEvent) => { const handleSearch = (ev: FormEvent) => {
ev.preventDefault () ev.preventDefault ()
@@ -46,10 +42,10 @@ const WikiSearchPage: FC<{ resourceType?: WikiPage['resourceType'] }> = (
return ( return (
<MainArea> <MainArea>
<PageHead title={message (heading)}/> <PageHead title={message ('navigation:wiki')}/>
<div className="max-w-xl"> <div className="max-w-xl">
<PageTitle>{label (heading)}</PageTitle> <PageTitle>{label ('navigation:wiki')}</PageTitle>
<form onSubmit={handleSearch} className="space-y-2"> <form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */} {/* タイトル */}
<FormField label={label ('labels.title')}> <FormField label={label ('labels.title')}>