このコミットが含まれているのは:
2026-10-20 20:29:18 +09:00
コミット 7afb96d226
21個のファイルの変更、617行の追加、66行の削除
+2 -1
ファイルの表示
@@ -255,7 +255,8 @@ class MaterialsController < ApplicationController
end
def resolve_material_tag! locale, tag_name_raw
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material)
Tag.find_or_create_by_tag_name!(locale, tag_name_raw, category: :material,
created_by_user: current_user)
end
def material_index_needs_tag_name? filters
+19 -5
ファイルの表示
@@ -98,7 +98,8 @@ class NicoTagsController < ApplicationController
ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(locale, linked_tag_names,
with_tagme: false,
with_no_deerjikist: false)
with_no_deerjikist: false,
created_by_user: current_user)
TagVersioning.record_tag_snapshots!(
linked_tags,
@@ -140,10 +141,23 @@ class NicoTagsController < ApplicationController
end
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
names =
case record
when TagName
[record]
when Tag
record.tag_names.to_a
else
[]
end
messages = names.flat_map do |tag_name|
tag_name.errors.full_messages.map do |message|
"タグ名 “#{ tag_name.name }”: #{ message }"
end
end
if messages.any?
render_validation_error fields: { tags: messages }
else
render_validation_error record
end
+4 -2
ファイルの表示
@@ -818,7 +818,8 @@ class PostsController < ApplicationController
with_tagme: false,
with_no_deerjikist: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
with_sections: true,
created_by_user: current_user) => { tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
@@ -964,7 +965,8 @@ class PostsController < ApplicationController
Tag.normalise_tags!(locale, snapshot[:tag_names],
with_tagme: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
with_sections: true,
created_by_user: current_user) => { tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user,
language_code: locale.language_code)
+57
ファイルの表示
@@ -0,0 +1,57 @@
class SitemapEntriesController < ApplicationController
PAGE_SIZE = 1_000
RESOURCES = [
'posts', 'tags', 'deerjikists', 'materials',
'descriptions', 'tag_groups', 'theatres'].freeze
def index
locale = resolve_locale!
resources = RESOURCES.map { |resource| { resource:, count: entries(resource, locale).count } }
render json: { resources:, page_size: PAGE_SIZE }
end
def show
locale = resolve_locale!
resource = params[:resource]
return head :not_found unless RESOURCES.include?(resource)
page = Integer(params.fetch(:page, '1'), exception: false)
return head :bad_request unless page && page.positive?
scope = entries(resource, locale)
return head :not_found if page > [(scope.count.to_f / PAGE_SIZE).ceil, 1].max
ids = scope.order(:id).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).pluck(:id)
render json: { ids: }
end
private
def entries resource, locale
named_tags = TagName.where(language_code: locale.language_code, primary_flg: true)
.select(:tag_id)
case resource
when 'posts'
Post.all
when 'tags'
Tag.where(id: named_tags)
when 'deerjikists'
Tag.where(id: named_tags).where(id: Deerjikist.select(:tag_id))
when 'materials'
Material.all
when 'descriptions'
TagDescription.where(language_code: locale.language_code, tag_id: named_tags)
.where.not(body: [nil, ''])
.where(TagDescriptionVersion
.where('tag_description_versions.tag_id = tag_descriptions.tag_id')
.where('tag_description_versions.language_code = tag_descriptions.language_code')
.where('tag_description_versions.version_no = tag_descriptions.version_no')
.arel.exists)
when 'tag_groups'
TagGroup.joins(:versions)
.where('tag_group_versions.version_no = tag_groups.version_no')
when 'theatres'
Theatre.kept
end
end
end
+2 -1
ファイルの表示
@@ -844,7 +844,8 @@ class TagsController < ApplicationController
def update_parent_tags! locale, tag, parent_names
parent_tags = Tag.normalise_tags!(locale, parent_names,
with_tagme: false,
with_no_deerjikist: false)
with_no_deerjikist: false,
created_by_user: current_user)
old_parent_tags = tag.parents.to_a
+9 -8
ファイルの表示
@@ -142,7 +142,8 @@ class Tag < ApplicationRecord
with_tagme: true,
with_no_deerjikist: true,
deny_deprecated: false,
with_sections: false
with_sections: false,
created_by_user: nil
if tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
@@ -171,7 +172,8 @@ class Tag < ApplicationRecord
name = TagName.canonicalise(locale, name).first
find_or_create_by_tag_name!(locale, name, category: (cat || :general)).tap do |tag|
find_or_create_by_tag_name!(locale, name, category: (cat || :general),
created_by_user:).tap do |tag|
if deny_deprecated && tag.deprecated?
raise DeprecatedTagNormalisationError, [tag.name(locale.language_code)]
end
@@ -259,7 +261,7 @@ class Tag < ApplicationRecord
[left_end_ms, right_end_ms].max
end
def find_or_create_by_tag_name! locale, name, category:
def find_or_create_by_tag_name! locale, name, category:, created_by_user: nil
language_code = locale.language_code
name = name.to_s.strip
@@ -268,11 +270,10 @@ class Tag < ApplicationRecord
tn = TagName.lock.find_by(language_code:, name:)
next tn.tag if tn
tag = Tag.create!(category:)
TagName.create!(tag:, language_code:, name:,
script_code: locale.script_code, primary_flg: true,
auto_generated: false)
TagNameCompletion.for_tag!(tag)
tag = Tag.new(category:)
tag.tag_names.build(language_code:, name:, script_code: locale.script_code,
primary_flg: true, auto_generated: false)
TagNameCompletion.for_tag!(tag, created_by_user:)
tag.association(:tag_names).reset
tag
+1 -1
ファイルの表示
@@ -105,7 +105,7 @@ class PostCreator
category = spec[:category] || spec['category']
next if name.blank? || category.blank?
tag = Tag.find_or_create_by_tag_name!(locale, name, category:)
tag = Tag.find_or_create_by_tag_name!(locale, name, category:, created_by_user: @actor)
tag.update!(category:) if tag.category.to_sym != category.to_sym
tags[name] ||= tag
end.values
+11 -6
ファイルの表示
@@ -11,13 +11,14 @@ class TagNameCompletion
end
Tag.where(id: certified_names.keys).lock.find_each(order: :asc) do |tag|
for_tag!(tag, locales: [locale],
certified_names: { locale.language_code => certified_names.fetch(tag.id) })
certified_names: { locale.language_code => certified_names.fetch(tag.id) },
created_by_user: nil)
end
named_tags =
TagName.where(language_code: locale.language_code, primary_flg: true)
.select(:tag_id)
Tag.where.not(id: named_tags).lock.find_each(order: :asc) do |tag|
for_tag!(tag, locales: [locale])
for_tag!(tag, locales: [locale], created_by_user: nil)
end
true
end
@@ -26,12 +27,15 @@ class TagNameCompletion
end
end
def for_tag! tag, locales: nil, certified_names: { }
def for_tag! tag, locales: nil, certified_names: { }, created_by_user: nil
locales ||=
Locale.where(deprecated_at: nil)
.order(:language_code, :created_at, :script_code).lock
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
# Creation includes every language in its first snapshot.
creating = tag.new_record?
tag.save! if creating
completed = tag.with_lock do
primary_names = tag.tag_names.where(primary_flg: true).lock.index_by(&:language_code)
certified_names.each do |language, name|
@@ -46,9 +50,9 @@ class TagNameCompletion
missing_locales = locales.to_a.uniq(&:language_code).reject { |locale|
primary_names.key?(locale.language_code)
}
next true if missing_locales.empty?
next true if missing_locales.empty? && !creating
TagVersioning.ensure_snapshot!(tag, created_by_user: nil)
TagVersioning.ensure_snapshot!(tag, created_by_user:) unless creating
missing_locales.each do |locale|
primary_names[locale.language_code] =
if certified_names.key?(locale.language_code)
@@ -60,7 +64,8 @@ class TagNameCompletion
end
end
tag.association(:tag_names).reset
version = TagVersioning.record!(tag, event_type: :update, created_by_user: nil)
event_type = creating ? :create : :update
version = TagVersioning.record!(tag, event_type:, created_by_user:)
unless version&.persisted?
raise ActiveRecord::RecordNotSaved, 'Tag version was not persisted'
end
+1
ファイルの表示
@@ -1,5 +1,6 @@
Rails.application.routes.draw do
resources :locales, only: [:index]
resources :sitemap_entries, only: [:index, :show], param: :resource
resources :tag_groups, only: [:index, :show, :create, :update]
+66
ファイルの表示
@@ -57,6 +57,7 @@ RSpec.describe Locale, type: :model do
tag = create(:tag, primary_name: 'already_named')
existing = create(:tag_name, name: 'existing_ainu', tag:,
language_code: 'ain', script_code: 'Kana')
versions_before = tag.tag_versions.order(:version_no).map(&:attributes)
create_ainu_locale!
@@ -65,6 +66,7 @@ RSpec.describe Locale, type: :model do
expect(existing.reload).to have_attributes(
tag_id: tag.id, language_code: 'ain', script_code: 'Kana',
primary_flg: true, auto_generated: false)
expect(tag.tag_versions.order(:version_no).map(&:attributes)).to eq(versions_before)
end
it 'adds the new language primary while preserving existing names and aliases' do
@@ -88,6 +90,7 @@ RSpec.describe Locale, type: :model do
prepare_ainu_reference!
first = create(:tag, primary_name: 'atomic_first')
second = create(:tag, primary_name: 'atomic_second')
versions_before = TagVersion.order(:id).map(&:attributes)
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name)
.with(kind_of(described_class), second, primary_tag_name_for(second, 'ja'))
@@ -97,6 +100,69 @@ RSpec.describe Locale, type: :model do
expect(described_class.exists?(code: 'ain-Kana')).to be(false)
expect(TagName.where(language_code: 'ain', tag_id: [first.id, second.id])).to be_empty
expect(TagVersion.order(:id).map(&:attributes)).to eq(versions_before)
end
it 'records a separate automatic update for each tag without changing its authored create' do
author = create(:user, :admin)
tags = ['locale_history_first', 'locale_history_second'].map do |name|
Tag.find_or_create_by_tag_name!(Locale.nipponese, name, category: :general,
created_by_user: author)
end
originals = tags.map { |tag| tag.tag_versions.sole.attributes }
expect { create_ainu_locale! }.to change(TagVersion, :count).by(2)
tags.zip(originals).each do |tag, original|
versions = tag.tag_versions.order(:version_no).to_a
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.first.attributes).to eq(original)
expect(versions.first.created_by_user_id).to eq(author.id)
expect(versions.last).to have_attributes(version_no: 2, created_by_user_id: nil)
expect(versions.last.names.fetch('ain')).to eq(
'name' => tag.name('ja'), 'script_code' => 'Kana',
'auto_generated' => true, 'aliases' => [])
expect(versions.first.names.keys).to eq(['ja'])
expect(tag.reload.version_no).to eq(2)
end
expect { TagNameCompletion.for_locale!(Locale.find('ain-Kana')) }
.not_to change(TagVersion, :count)
end
it 'records automatic before and after snapshots for a tag without any history' do
tag = create(:tag, primary_name: 'legacy_before_locale')
expect(tag.tag_versions).to be_empty
create_ainu_locale!
versions = tag.tag_versions.order(:version_no).to_a
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.map(&:created_by_user_id)).to eq([nil, nil])
expect(versions.first.names.keys).to eq(['ja'])
expect(versions.last.names.keys).to match_array(['ja', 'ain'])
expect(versions.last.names.fetch('ja')).to eq(versions.first.names.fetch('ja'))
end
it 'rolls back earlier tag names and histories when a later history fails' do
first = create(:tag, primary_name: 'history_atomic_first')
second = create(:tag, primary_name: 'history_atomic_second')
names_before = TagName.order(:id).map(&:attributes)
versions_before = TagVersion.order(:id).map(&:attributes)
allow(TagVersioning).to receive(:record!).and_wrap_original do |original, tag, **options|
if tag.id == second.id && options[:event_type] == :update
raise ActiveRecord::RecordNotSaved, 'history failed'
end
original.call(tag, **options)
end
expect { create_ainu_locale! }
.to raise_error(ActiveRecord::RecordNotSaved, 'history failed')
expect(described_class.exists?(code: 'ain-Kana')).to be(false)
expect(TagName.order(:id).map(&:attributes)).to eq(names_before)
expect(TagVersion.order(:id).map(&:attributes)).to eq(versions_before)
expect([first.reload.version_no, second.reload.version_no]).to eq([1, 1])
end
it 'rejects a private locale code outside the supported locale set' do
+13 -1
ファイルの表示
@@ -14,7 +14,19 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
expect(tag_name).to have_attributes(
script_code: expected_locale.script_code, primary_flg: true)
expect(tag_name.tag_id).to be_present
tag_name.tag
tag = tag_name.tag
version = tag.tag_versions.sole
expect(version).to have_attributes(
event_type: 'create', version_no: 1, created_by_user_id: member.id)
expect(tag.reload.version_no).to eq(1)
expect(version.names.keys).to match_array(
Locale.where(deprecated_at: nil).distinct.pluck(:language_code))
tag.tag_names.where(primary_flg: true).each do |primary|
expect(version.names.fetch(primary.language_code)).to include(
'name' => primary.name, 'script_code' => primary.script_code,
'auto_generated' => primary.auto_generated, 'aliases' => [])
end
tag
end
it 'confirms a generated Nipponese primary when a human renames it' do
+3
ファイルの表示
@@ -231,11 +231,14 @@ RSpec.describe 'NicoTags', type: :request do
replacement: 'valid'
)
nico_tag
before_counts = [Tag.count, TagName.count, TagVersion.count]
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'created_first invalid' }
}.not_to change(TagName, :count)
expect([Tag.count, TagName.count, TagVersion.count]).to eq(before_counts)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors').fetch('tags')).to include(
a_string_including('タグ名 “invalid”:', '名前に使用できない文字が含まれてゐます.'))
+155
ファイルの表示
@@ -0,0 +1,155 @@
require 'rails_helper'
RSpec.describe 'Sitemap entries API', type: :request do
include_context 'Ainu locale'
let(:user) { create(:user, :member) }
it 'serves public resource counts with the default locale and page size' do
create(:post)
create(:tag, primary_name: 'sitemap_count_tag')
get '/sitemap_entries'
expect(response).to have_http_status(:ok)
expect(json.fetch('page_size')).to eq(1_000)
counts = json.fetch('resources').to_h { |entry|
[entry.fetch('resource'), entry.fetch('count')]
}
expect(counts).to eq(
'posts' => Post.count, 'tags' => Tag.count, 'deerjikists' => 0,
'materials' => 0, 'descriptions' => 0, 'tag_groups' => 0, 'theatres' => 0)
end
it 'paginates public post IDs in ascending order without exposing post contents' do
stub_const('SitemapEntriesController::PAGE_SIZE', 2)
posts = create_list(:post, 3)
get '/sitemap_entries/posts'
expect(response).to have_http_status(:ok)
expect(json).to eq('ids' => posts.first(2).map(&:id))
get '/sitemap_entries/posts', params: { page: 2 }
expect(response).to have_http_status(:ok)
expect(json).to eq('ids' => [posts.last.id])
get '/sitemap_entries/posts', params: { page: 3 }
expect(response).to have_http_status(:not_found)
end
['0', '-1', 'invalid', '1.5'].each do |page|
it "rejects invalid sitemap page #{ page.inspect }" do
get '/sitemap_entries/posts', params: { page: }
expect(response).to have_http_status(:bad_request)
end
end
it 'rejects unsupported resource names' do
get '/sitemap_entries/users'
expect(response).to have_http_status(:not_found)
end
it 'returns an empty first page for a resource without entries' do
get '/sitemap_entries/posts'
expect(response).to have_http_status(:ok)
expect(json).to eq('ids' => [])
end
it 'rejects unregistered, deprecated and unsupported locales' do
get '/sitemap_entries', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:not_found)
locale.update!(deprecated_at: Time.current)
['/sitemap_entries', '/sitemap_entries/posts'].each do |path|
get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:not_found)
get path, params: { locale: 'en' }
expect(response).to have_http_status(:not_found)
end
end
it 'only lists live materials and theatres' do
material = Material.create!(url: 'https://example.com/sitemap-material')
discarded_material = Material.create!(url: 'https://example.com/sitemap-discarded')
discarded_material.discard!
theatre = create(:theatre)
create(:theatre).discard!
get '/sitemap_entries/materials'
expect(response).to have_http_status(:ok)
expect(json).to eq('ids' => [material.id])
get '/sitemap_entries/theatres'
expect(response).to have_http_status(:ok)
expect(json).to eq('ids' => [theatre.id])
end
context 'with an active Ainu locale' do
before { locale }
it 'uses tag IDs and only includes deerjikists with linked accounts' do
tagged = create(:tag, primary_name: 'sitemap_deer', category: :deerjikist)
unlinked = create(:tag, primary_name: 'sitemap_unlinked', category: :deerjikist)
Deerjikist.create!(tag: tagged, platform: 'youtube', code: 'sitemap-channel')
Deerjikist.create!(tag: tagged, platform: 'nico', code: 'sitemap-nico')
get '/sitemap_entries/tags', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
expect(json.fetch('ids')).to contain_exactly(tagged.id, unlinked.id)
get '/sitemap_entries/deerjikists', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
expect(json).to eq('ids' => [tagged.id])
end
it 'lists description IDs for the requested language with a nonempty current version' do
tag = create(:tag, primary_name: 'sitemap_description')
japanese = TagDescriptionOperation.create!(
Locale.nipponese, tag:, body: 'Japanese sitemap body', created_by_user: user)
ainu = TagDescriptionOperation.create!(
locale, tag:, body: 'Ainu sitemap body', created_by_user: user)
unversioned_tag = create(:tag, primary_name: 'sitemap_unversioned')
TagDescription.create!(tag: unversioned_tag, language_code: 'ain',
body: 'No revision', version_no: 1)
empty_tag = create(:tag, primary_name: 'sitemap_empty')
# Legacy empty bodies can exist even though the write service rejects them.
empty = TagDescription.create!(tag: empty_tag, language_code: 'ain',
body: '', version_no: 1)
TagDescriptionVersion.create!(tag_id: empty_tag.id, language_code: 'ain',
body: '', version_no: 1, event_type: 'create',
created_by_user_id: user.id, created_at: Time.current)
get '/sitemap_entries/descriptions', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
expect(json).to eq('ids' => [ainu.id])
expect(json.fetch('ids')).not_to include(japanese.id, empty.id)
get '/sitemap_entries/descriptions', params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
expect(json).to eq('ids' => [japanese.id])
get '/sitemap_entries', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
descriptions = json.fetch('resources').find { |entry|
entry.fetch('resource') == 'descriptions'
}
expect(descriptions.fetch('count')).to eq(1)
end
it 'excludes groups without a revision matching the current version' do
group = TagGroupOperation.create!(
Locale.nipponese, name: 'Sitemap group', description: 'Group description',
members: [], placements: [], actor: user)
TagGroup.create!(name: 'Unversioned sitemap group', version_no: 1)
get '/sitemap_entries/tag_groups', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
expect(json).to eq('ids' => [group.id])
end
end
end
+2 -1
ファイルの表示
@@ -95,7 +95,8 @@ RSpec.describe PostCreator do
post = described_class.new(actor:, attributes:).create!(locale)
expect(Tag).to have_received(:find_or_create_by_tag_name!)
.with(locale, 'locale_character', category: :character).at_least(:once)
.with(locale, 'locale_character', category: :character,
created_by_user: actor).at_least(:once)
tag_name = TagName.find_by!(language_code: 'ain', name: 'locale_character')
expect(tag_name).to have_attributes(script_code: 'Kana', primary_flg: true)
expect(post.tags).to include(tag_name.tag)
+59
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper'
RSpec.describe TagNameCompletion do
let(:author) { create(:user, :member) }
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
let(:locale) do
@@ -65,4 +66,62 @@ RSpec.describe TagNameCompletion do
.to contain_exactly(['ja', 'アイヌ'], ['ain', 'アイヌ'])
expect(Tag.find_or_create_by_tag_name!(locale, 'アイヌ', category: :general)).to eq(tag)
end
['ja', 'ain'].each do |language|
it "records one authored create with all names for a #{ language } origin" do
ainu_locale = locale
origin = language == 'ja' ? Locale.nipponese : ainu_locale
tag = nil
expect {
tag = Tag.find_or_create_by_tag_name!(origin, 'アイヌ', category: :general,
created_by_user: author)
}.to change(TagVersion, :count).by(1)
version = tag.tag_versions.sole
expect(version).to have_attributes(
event_type: 'create', version_no: 1, created_by_user_id: author.id)
expect(tag.reload.version_no).to eq(1)
expect(version.names).to eq(
'ja' => { 'name' => 'アイヌ', 'script_code' => 'Jpan',
'auto_generated' => language != 'ja', 'aliases' => [] },
'ain' => { 'name' => 'アイヌ', 'script_code' => 'Kana',
'auto_generated' => language != 'ain', 'aliases' => [] })
expect {
described_class.for_tag!(tag, created_by_user: author)
found = Tag.find_or_create_by_tag_name!(origin, 'アイヌ', category: :general,
created_by_user: author)
expect(found).to eq(tag)
TagVersioning.record_tag_snapshot!(tag, created_by_user: author)
}.not_to change(TagVersion, :count)
expect(version.reload.created_by_user_id).to eq(author.id)
end
end
it 'records the author even when no additional language needs completion' do
tag = build(:tag, primary_name: 'single_language_creation')
described_class.for_tag!(tag, created_by_user: author)
expect(tag).to be_persisted
expect(tag.tag_versions.sole).to have_attributes(
event_type: 'create', version_no: 1, created_by_user_id: author.id)
expect(tag.tag_versions.sole.names.keys).to eq(['ja'])
end
it 'rolls back a new tag and all names when its create history fails' do
locale
author
before_counts = [Tag.count, TagName.count, TagVersion.count]
allow(TagVersioning).to receive(:record!).and_raise('history failed')
expect {
Tag.find_or_create_by_tag_name!(Locale.nipponese, 'atomic_new_history',
category: :general, created_by_user: author)
}.to raise_error('history failed')
expect([Tag.count, TagName.count, TagVersion.count]).to eq(before_counts)
expect(TagName.where(name: 'atomic_new_history')).to be_empty
end
end