コミットを比較
28
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
e187331af4 | ||
|
|
b3b00e170f | ||
|
|
c9628a120d | ||
|
|
525f6883c9 | ||
|
|
20085d5017 | ||
|
|
bbee83e35b | ||
|
|
1dcb790f44 | ||
|
|
b3c42b8eb3 | ||
|
|
a7e350d28e | ||
|
|
d9bfac8392 | ||
|
|
4c1178dab5 | ||
|
|
2d1a8b4730 | ||
|
|
61d7e6f44e | ||
|
|
753a150128 | ||
|
|
cd948611af | ||
|
|
34a4ffefb4 | ||
|
|
eda81cfa89 | ||
|
|
0c1f459d4e | ||
|
|
9be0c7b1d9 | ||
|
|
63860c94af | ||
|
|
851d5e8e09 | ||
|
|
91296f7477 | ||
|
|
f063fc54bb | ||
|
|
b945ceef60 | ||
|
|
75d7dac999 | ||
|
|
e44887cf15 | ||
|
|
534a66f90d | ||
|
|
592932b5a5 |
@@ -1,10 +1,16 @@
|
||||
class ApplicationController < ActionController::API
|
||||
class UnsupportedLocale < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
|
||||
rescue_from(UnsupportedLocale) { head :not_found }
|
||||
|
||||
before_action :reject_banned_ip_address!
|
||||
before_action :authenticate_user
|
||||
before_action :reject_banned_user!
|
||||
before_action :validate_explicit_locale!
|
||||
|
||||
def current_user = @current_user
|
||||
|
||||
@@ -93,5 +99,21 @@ class ApplicationController < ActionController::API
|
||||
value
|
||||
end
|
||||
|
||||
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese
|
||||
def resolve_locale!
|
||||
return @resolved_locale if defined?(@resolved_locale)
|
||||
|
||||
code = params.fetch(:locale, 'ja')
|
||||
unless code.is_a?(String) && Locale.codes.value?(code)
|
||||
raise UnsupportedLocale
|
||||
end
|
||||
|
||||
locale = Locale.find_by(code:)
|
||||
raise UnsupportedLocale unless locale && locale.deprecated_at.nil?
|
||||
|
||||
@resolved_locale = locale
|
||||
end
|
||||
|
||||
def validate_explicit_locale!
|
||||
resolve_locale! if params.key?(:locale)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class LocalesController < ApplicationController
|
||||
def index
|
||||
locales = Locale.where(deprecated_at: nil).order(:created_at).map do |locale|
|
||||
{ code: locale.code_before_type_cast, name: locale.name }
|
||||
end
|
||||
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
render json: { locales: }
|
||||
end
|
||||
end
|
||||
@@ -70,7 +70,10 @@ class MaterialsController < ApplicationController
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless material
|
||||
|
||||
wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.body
|
||||
wiki_page_body =
|
||||
TagDescription
|
||||
.find_by(tag_id: material.tag_id, language_code: locale.language_code)
|
||||
&.body
|
||||
|
||||
render json: MaterialRepr.base(material, host: request.base_url,
|
||||
language_code: locale.language_code).merge(wiki_page_body:)
|
||||
|
||||
@@ -26,7 +26,7 @@ class NicoTagsController < ApplicationController
|
||||
ExternalTag
|
||||
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
|
||||
'ON post_tag_max.external_tag_id = external_tags.id')
|
||||
.includes(linked_tags: { tag_names: :wiki_page })
|
||||
.includes(linked_tags: [:tag_names, :tag_descriptions])
|
||||
if name
|
||||
q = q.where(('external_tags.name LIKE ? ' +
|
||||
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class PostVersionsController < ApplicationController
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
post_id = params[:post].presence
|
||||
tag_id = params[:tag].presence&.to_i
|
||||
external_tag_id = params[:external_tag].presence&.to_i
|
||||
@@ -45,12 +46,12 @@ class PostVersionsController < ApplicationController
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
render json: { versions: serialise_versions(versions), count: }
|
||||
render json: { versions: serialise_versions(versions, locale.language_code), count: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialise_versions rows
|
||||
def serialise_versions rows, language_code
|
||||
rows = rows.to_a
|
||||
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
||||
@@ -58,12 +59,16 @@ class PostVersionsController < ApplicationController
|
||||
[normalise_json(row.tags_json),
|
||||
normalise_json(row.attributes['prev_tags_json']) || []]
|
||||
}
|
||||
tag_ids = snapshots.flatten.filter_map { _1['tag_id'] }.uniq
|
||||
tag_names = TagName.where(tag_id: tag_ids, language_code:, primary_flg: true)
|
||||
.pluck(:tag_id, :name).to_h
|
||||
external_tag_names = external_tag_names_for(snapshots)
|
||||
|
||||
rows.map do |row|
|
||||
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names)
|
||||
cur_tags = snapshot_tag_literals(
|
||||
normalise_json(row.tags_json), tag_names, external_tag_names)
|
||||
prev_tags = snapshot_tag_literals(
|
||||
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names)
|
||||
normalise_json(row.attributes['prev_tags_json']) || [], tag_names, external_tag_names)
|
||||
|
||||
{ post_id: row.post_id,
|
||||
version_no: row.version_no,
|
||||
@@ -75,6 +80,7 @@ class PostVersionsController < ApplicationController
|
||||
prev: row.attributes['prev_thumbnail_base'] },
|
||||
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
|
||||
tags: build_version_tags(cur_tags, prev_tags),
|
||||
parent_post_ids: row.parent_post_ids,
|
||||
original_created_from: {
|
||||
current: row.original_created_from&.iso8601,
|
||||
prev: row.attributes['prev_original_created_from']&.iso8601 },
|
||||
@@ -106,17 +112,27 @@ class PostVersionsController < ApplicationController
|
||||
names
|
||||
end
|
||||
|
||||
def snapshot_tag_literals snapshots, external_tag_names
|
||||
def snapshot_tag_literals snapshots, tag_names, external_tag_names
|
||||
snapshots
|
||||
.filter_map { |snapshot|
|
||||
if snapshot.key?('tag_id')
|
||||
sections = snapshot.fetch('sections', []).map { |section|
|
||||
[section.fetch('begin_ms'), section['end_ms']]
|
||||
}.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
|
||||
name = tag_names[snapshot['tag_id']] || snapshot.fetch('name')
|
||||
[tag_category_order(snapshot['category']),
|
||||
Post.tag_snapshot_literal(snapshot)]
|
||||
{ identity: [:tag, snapshot['tag_id'], sections],
|
||||
name: Post.tag_snapshot_literal(snapshot.merge('name' => name)),
|
||||
restore_name: tag_names[snapshot['tag_id']] &&
|
||||
Post.tag_snapshot_literal(snapshot.merge('name' => name)),
|
||||
external: false }]
|
||||
elsif external_tag_names[snapshot['external_tag_id']]
|
||||
[6, external_tag_names[snapshot['external_tag_id']]]
|
||||
[6, { identity: [:external_tag, snapshot['external_tag_id']],
|
||||
name: external_tag_names[snapshot['external_tag_id']],
|
||||
restore_name: nil, external: true }]
|
||||
end
|
||||
}
|
||||
.sort_by { |order, name| [order, name.downcase] }
|
||||
.sort_by { |order, tag| [order, tag[:name].downcase] }
|
||||
.map(&:second)
|
||||
end
|
||||
|
||||
@@ -125,17 +141,22 @@ class PostVersionsController < ApplicationController
|
||||
end
|
||||
|
||||
def build_version_tags cur_tags, prev_tags
|
||||
(cur_tags | prev_tags).map do |name|
|
||||
current = cur_tags.index_by { _1[:identity] }
|
||||
previous = prev_tags.index_by { _1[:identity] }
|
||||
|
||||
(current.keys | previous.keys).map do |identity|
|
||||
type =
|
||||
if cur_tags.include?(name) && prev_tags.include?(name)
|
||||
if current.key?(identity) && previous.key?(identity)
|
||||
'context'
|
||||
elsif cur_tags.include?(name)
|
||||
elsif current.key?(identity)
|
||||
'added'
|
||||
else
|
||||
'removed'
|
||||
end
|
||||
|
||||
{ name:, type: }
|
||||
entry = current[identity] || previous[identity]
|
||||
{ name: entry[:name], restore_name: entry[:restore_name],
|
||||
external: entry[:external], type: }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,7 +53,7 @@ class PostsController < ApplicationController
|
||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
||||
.preload(:external_tags, :uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
|
||||
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
||||
@@ -111,7 +111,7 @@ class PostsController < ApplicationController
|
||||
filtered_posts(locale)
|
||||
.preload(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
.order('RAND()')
|
||||
.first
|
||||
@@ -198,7 +198,7 @@ class PostsController < ApplicationController
|
||||
Post
|
||||
.includes(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless post
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
class TagDescriptionsController < ApplicationController
|
||||
def show
|
||||
description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja')
|
||||
locale = resolve_locale!
|
||||
description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code)
|
||||
return head :not_found unless description
|
||||
|
||||
render json: representation(description)
|
||||
end
|
||||
|
||||
def show_by_title
|
||||
tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip)
|
||||
description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja')
|
||||
locale = resolve_locale!
|
||||
tag_name = TagName.find_by(
|
||||
language_code: locale.language_code, name: params[:name].to_s.strip)
|
||||
description = tag_name&.tag && TagDescription.find_by(
|
||||
tag: tag_name.tag, language_code: locale.language_code)
|
||||
return head :not_found unless description
|
||||
|
||||
render json: representation(description)
|
||||
@@ -21,18 +25,19 @@ class TagDescriptionsController < ApplicationController
|
||||
body = params[:body].to_s
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
locale = resolve_locale!
|
||||
tag = Tag.find_by(id: params[:id])
|
||||
return head :not_found unless tag
|
||||
|
||||
description = TagDescription.find_by(tag:, language_code: 'ja')
|
||||
description = TagDescription.find_by(tag:, language_code: locale.language_code)
|
||||
|
||||
if description
|
||||
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user)
|
||||
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
|
||||
description.reload
|
||||
render json: representation(description)
|
||||
else
|
||||
TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user)
|
||||
description = TagDescription.find_by!(tag:, language_code: 'ja')
|
||||
TagDescriptionOperation.create!(locale, tag:, body:, created_by_user: current_user)
|
||||
description = TagDescription.find_by!(tag:, language_code: locale.language_code)
|
||||
render json: representation(description), status: :created
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
class TagGroupsController < ApplicationController
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
|
||||
render json: TagGroupRepr.many(locale, groups.to_a)
|
||||
end
|
||||
|
||||
def show
|
||||
locale = resolve_locale!
|
||||
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
render json: TagGroupRepr.one(locale, group)
|
||||
end
|
||||
|
||||
def create
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
locale = resolve_locale!
|
||||
group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(locale, group), status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
locale = resolve_locale!
|
||||
group = TagGroup.find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(locale, group.reload)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_attributes
|
||||
{ name: params[:name], description: params[:description],
|
||||
members: params[:members], placements: params[:placements] }
|
||||
end
|
||||
end
|
||||
@@ -51,7 +51,7 @@ class TagVersionsController < ApplicationController
|
||||
|
||||
tags_by_id =
|
||||
Tag
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.where(id: all_parent_tag_ids)
|
||||
.index_by(&:id)
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ class TagsController < ApplicationController
|
||||
tags_by_id =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||
id: tag_ids)
|
||||
.index_by(&:id)
|
||||
@@ -238,7 +238,7 @@ class TagsController < ApplicationController
|
||||
base =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||
deprecated_at: nil)
|
||||
base = base.where('tags.post_count > 0') if present_only
|
||||
@@ -282,7 +282,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(id: params[:id],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||
@@ -302,7 +302,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||
|
||||
@@ -320,7 +320,7 @@ class TagsController < ApplicationController
|
||||
|
||||
tag =
|
||||
Tag.joins(:tag_names)
|
||||
.includes(:tag_names, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(id: params[:id],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
@@ -340,7 +340,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
|
||||
@@ -359,7 +359,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(id: params[:id],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
@@ -368,6 +368,7 @@ class TagsController < ApplicationController
|
||||
return if performed?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
|
||||
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
|
||||
@@ -424,7 +425,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
|
||||
@@ -770,31 +771,15 @@ class TagsController < ApplicationController
|
||||
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
|
||||
end
|
||||
|
||||
current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id)
|
||||
current_tag_name.association(:wiki_page).reset
|
||||
|
||||
current_tag_name.update!(canonical_id: promoted_tag_name.id,
|
||||
primary_flg: false, auto_generated: false)
|
||||
promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true, auto_generated: false)
|
||||
|
||||
TagName.where(tag:,
|
||||
language_code: current_tag_name.language_code,
|
||||
primary_flg: false)
|
||||
.where.not(id: promoted_tag_name.id)
|
||||
.find_each do |alias_tag_name|
|
||||
alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
|
||||
end
|
||||
|
||||
tag.update!(tag_name_id: promoted_tag_name.id)
|
||||
current_tag_name.association(:wiki_page).reset
|
||||
current_tag_name.association(:tag).reset
|
||||
current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
|
||||
current_tag_name&.update!(primary_flg: false, auto_generated: false)
|
||||
promoted_tag_name.update!(tag:, primary_flg: true, auto_generated: false)
|
||||
tag.association(:tag_names).reset
|
||||
|
||||
return unless old_owner_tag && old_owner_tag != tag
|
||||
|
||||
record_tag_version!(old_owner_tag.reload, event_type: :update,
|
||||
created_by_user: current_user,
|
||||
language_code: current_tag_name.language_code)
|
||||
language_code: promoted_tag_name.language_code)
|
||||
end
|
||||
|
||||
def update_aliases! locale, tag, alias_names
|
||||
@@ -802,20 +787,15 @@ class TagsController < ApplicationController
|
||||
|
||||
affected_tags = [tag]
|
||||
|
||||
primary_tag_name = TagName.find_by!(tag:,
|
||||
language_code: locale.language_code,
|
||||
primary_flg: true)
|
||||
current_aliases = tag.tag_names.where(language_code: locale.language_code,
|
||||
primary_flg: false).to_a
|
||||
|
||||
current_aliases.each do |alias_tag_name|
|
||||
next if alias_names.include?(alias_tag_name.name)
|
||||
|
||||
affected_tags << alias_tag_name.tag
|
||||
end
|
||||
requested_aliases = TagName.where(
|
||||
language_code: locale.language_code, name: alias_names)
|
||||
.includes(:tag).index_by(&:name)
|
||||
|
||||
alias_names.each do |alias_name|
|
||||
alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name)
|
||||
alias_tag_name = requested_aliases[alias_name]
|
||||
if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id
|
||||
alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.')
|
||||
raise ActiveRecord::RecordInvalid, alias_tag_name
|
||||
@@ -823,7 +803,8 @@ class TagsController < ApplicationController
|
||||
affected_tags << alias_tag_name&.tag
|
||||
end
|
||||
|
||||
affected_tags.compact.uniq.each do |affected_tag|
|
||||
affected_tags = affected_tags.compact.uniq(&:id)
|
||||
affected_tags.each do |affected_tag|
|
||||
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user,
|
||||
language_code: locale.language_code)
|
||||
end
|
||||
@@ -831,19 +812,21 @@ class TagsController < ApplicationController
|
||||
current_aliases.each do |alias_tag_name|
|
||||
next if alias_names.include?(alias_tag_name.name)
|
||||
|
||||
alias_tag_name.update!(canonical_id: nil, tag_id: nil)
|
||||
alias_tag_name.destroy!
|
||||
end
|
||||
|
||||
alias_names.each do |alias_name|
|
||||
alias_tag_name = TagName.find_or_initialize_by(
|
||||
alias_tag_name = requested_aliases[alias_name] || TagName.new(
|
||||
language_code: locale.language_code,
|
||||
name: alias_name)
|
||||
alias_tag_name.script_code = locale.script_code
|
||||
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false,
|
||||
canonical_id: primary_tag_name.id)
|
||||
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false)
|
||||
end
|
||||
|
||||
affected_tags.compact.uniq.each do |affected_tag|
|
||||
affected_tags.each do |affected_tag|
|
||||
# update_all records this Tag after its parent relationships are updated.
|
||||
next if affected_tag.id == tag.id
|
||||
|
||||
record_tag_version!(affected_tag, event_type: :update,
|
||||
created_by_user: current_user,
|
||||
language_code: locale.language_code)
|
||||
|
||||
@@ -1,107 +1,154 @@
|
||||
class WikiPagesController < ApplicationController
|
||||
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
|
||||
# 互換性のための Facade API
|
||||
|
||||
rescue_from TagDescriptionOperation::Conflict, with: :render_wiki_conflict
|
||||
|
||||
def index
|
||||
# 互換性のための化石リソース
|
||||
# TODO: ヘルプおよびタグ・グループ追加後に,
|
||||
# TODO: タグ説明とヘルプ、タグ・グループから取得する作りに変更する.
|
||||
#
|
||||
title = params[:title].to_s.strip
|
||||
descriptions = TagDescription.where(language_code: 'ja')
|
||||
.includes(tag: :tag_names)
|
||||
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
|
||||
locale = resolve_locale!
|
||||
|
||||
title = params[:title].to_s.strip
|
||||
descriptions =
|
||||
TagDescription
|
||||
.where(language_code: locale.language_code)
|
||||
.includes(tag: :tag_names)
|
||||
|
||||
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
||||
tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
|
||||
if title.present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||
descriptions = descriptions.joins(tag: :tag_names)
|
||||
.where(tag_names: { language_code: 'ja', primary_flg: true })
|
||||
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)
|
||||
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||
tag_groups = tag_groups.where(
|
||||
'CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?',
|
||||
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
|
||||
end
|
||||
|
||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
||||
json.concat(WikiPageRepr.base(pages))
|
||||
json = descriptions.map { |description| WikiPageRepr.tag_description(locale, description) }
|
||||
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||
json.sort_by! do |row|
|
||||
[-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')]
|
||||
end
|
||||
json = json.first(20) if title.present?
|
||||
render json:
|
||||
end
|
||||
|
||||
def autolink_candidates
|
||||
locale = resolve_locale!
|
||||
language = params.fetch(:language, locale.language_code)
|
||||
return head :not_found unless language.is_a?(String)
|
||||
target_locale =
|
||||
if language == 'und'
|
||||
locale
|
||||
else
|
||||
Locale.where(language_code: language, deprecated_at: nil,
|
||||
code: Locale.codes.values).order(:code).first
|
||||
end
|
||||
return head :not_found unless target_locale
|
||||
|
||||
candidates = []
|
||||
unless language == 'und'
|
||||
names = TagDescription.joins(tag: :tag_names)
|
||||
.where(language_code: language, tag_names: { language_code: language })
|
||||
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
|
||||
candidates = names.map do |tag_id, description_id, name|
|
||||
WikiPageRepr.autolink_tag(target_locale, tag_id:, description_id:, name:)
|
||||
end
|
||||
end
|
||||
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
|
||||
WikiPageRepr.autolink_group(target_locale, id:, name:, language:)
|
||||
end)
|
||||
render json: candidates
|
||||
end
|
||||
|
||||
def show
|
||||
tag_description = TagDescription.find_by(id: params[:id])
|
||||
locale = resolve_locale!
|
||||
tag_description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
|
||||
|
||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||
return render_wiki_page_by_tag_description locale, tag_description if tag_description
|
||||
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(id: params[:id])
|
||||
tag_group = TagGroup.find_by(id: params[:id])
|
||||
return render_tag_group locale, tag_group if tag_group
|
||||
|
||||
render_wiki_page_or_404 page
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def show_by_title
|
||||
locale = resolve_locale!
|
||||
title = params[:title].to_s.strip
|
||||
|
||||
tag_description = tag_description_by_title(title)
|
||||
if (name = TagGroupRepr.name_from_title(title))
|
||||
tag_group = TagGroup.find_by(name:)
|
||||
return render_tag_group locale, tag_group if tag_group
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||
tag_description = tag_description_by_title(locale, title)
|
||||
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(tag_name: { name: title })
|
||||
return render_wiki_page_by_tag_description locale, tag_description if tag_description
|
||||
|
||||
render_wiki_page_or_404 page
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def exists
|
||||
return head :no_content if TagDescription.exists?(id: params[:id])
|
||||
locale = resolve_locale!
|
||||
if TagDescription.exists?(id: params[:id], language_code: locale.language_code)
|
||||
return head :no_content
|
||||
end
|
||||
return head :no_content if TagGroup.exists?(id: params[:id])
|
||||
|
||||
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def exists_by_title
|
||||
locale = resolve_locale!
|
||||
title = params[:title].to_s.strip
|
||||
return head :no_content if tag_description_by_title(title)
|
||||
if (name = TagGroupRepr.name_from_title(title))
|
||||
exists = TagGroup.exists?(name:)
|
||||
return head exists ? :no_content : :not_found
|
||||
end
|
||||
|
||||
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
||||
head exists ? :no_content : :not_found
|
||||
return head :no_content if tag_description_by_title(locale, title)
|
||||
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def diff
|
||||
id = params[:id]
|
||||
return render_bad_request('id は必須です.') if id.blank?
|
||||
|
||||
locale = resolve_locale!
|
||||
|
||||
from = params[:from].presence
|
||||
to = params[:to].presence
|
||||
|
||||
description = TagDescription.find_by(id:)
|
||||
description = TagDescription.find_by(id:, language_code: locale.language_code)
|
||||
if description
|
||||
versions = description_versions(description)
|
||||
from_rev = from && versions.find(from)
|
||||
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
|
||||
resource = WikiPageRepr.tag_description(description)
|
||||
resource = WikiPageRepr.tag_description(locale, description)
|
||||
else
|
||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
||||
from_rev = from && page.wiki_revisions.find(from)
|
||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
|
||||
return render_unprocessable_entity('差分を表示できない版です.')
|
||||
tag_group = TagGroup.find_by(id:)
|
||||
if tag_group
|
||||
from_rev = from && group_version(tag_group, from)
|
||||
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
|
||||
return head :not_found if (from && !from_rev) || !to_rev
|
||||
|
||||
from_body = from_rev && render_group_version(locale, from_rev)
|
||||
to_body = render_group_version(locale, to_rev)
|
||||
resource = { 'id' => tag_group.id,
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||
'deprecated_at' => nil }
|
||||
else
|
||||
return head :not_found
|
||||
end
|
||||
resource = WikiPageRepr.base(page)
|
||||
end
|
||||
|
||||
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
|
||||
diff_json = diffs.map { |change|
|
||||
case change.action
|
||||
when ?=
|
||||
{ type: 'context', content: change.old_element }
|
||||
when ?!
|
||||
[{ type: 'removed', content: change.old_element },
|
||||
{ type: 'added', content: change.new_element }]
|
||||
when ?+
|
||||
{ type: 'added', content: change.new_element }
|
||||
when ?-
|
||||
{ type: 'removed', content: change.old_element }
|
||||
end
|
||||
}.flatten.compact
|
||||
from_body ||= from_rev&.body
|
||||
to_body ||= to_rev.body
|
||||
diff_json = wiki_diff(from_body, to_body)
|
||||
|
||||
render json: { wiki_page_id: resource.fetch('id'),
|
||||
title: resource.fetch('title'),
|
||||
@@ -109,6 +156,8 @@ class WikiPagesController < ApplicationController
|
||||
older_revision_id: from_rev&.id,
|
||||
newer_revision_id: to_rev.id,
|
||||
diff: diff_json }
|
||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -122,30 +171,37 @@ class WikiPagesController < ApplicationController
|
||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
page = if title.include?(':')
|
||||
ApplicationRecord.transaction do
|
||||
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
|
||||
name.script_code = 'Jpan'
|
||||
name.primary_flg = true
|
||||
name.auto_generated = false
|
||||
end
|
||||
locale = resolve_locale!
|
||||
|
||||
Wiki::Commit.create_legacy_content!(
|
||||
tag_name:, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
else
|
||||
tag_name = TagName.find_by(language_code: 'ja', name: title)
|
||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||
|
||||
TagDescriptionOperation.create!(
|
||||
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
||||
if (name = TagGroupRepr.name_from_title(title))
|
||||
parsed = TagGroupMarkdownParser.parse(locale, body:, group: nil)
|
||||
group = TagGroupOperation.create!(
|
||||
locale, name:, **parsed.except(:compatibility_body), actor: current_user,
|
||||
require_compatibility: true)
|
||||
version = current_group_version(group)
|
||||
return render_group_representation(TagGroupRepr.one(locale, group), version,
|
||||
status: :created)
|
||||
end
|
||||
|
||||
render json: WikiPageRepr.base(page), status: :created
|
||||
description =
|
||||
if title.include?(':')
|
||||
return render_unprocessable_entity field: :title
|
||||
else
|
||||
tag_name = TagName.find_by(language_code: locale.language_code, name: title)
|
||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||
|
||||
TagDescriptionOperation.create!(
|
||||
locale,
|
||||
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
|
||||
render json: WikiPageRepr.tag_description(locale, description), status: :created
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_validation_error e.record
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
render_record_not_unique
|
||||
rescue TagGroupMarkdownParser::InvalidDocument => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -158,93 +214,178 @@ class WikiPagesController < ApplicationController
|
||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
page = WikiPage.find(params[:id])
|
||||
locale = resolve_locale!
|
||||
|
||||
group = TagGroup.find_by(id: params[:id])
|
||||
if (name = TagGroupRepr.name_from_title(title))
|
||||
return head :not_found unless group
|
||||
|
||||
parsed = TagGroupMarkdownParser.parse(locale, body:, group:)
|
||||
TagGroupOperation.update!(
|
||||
locale, group:, name:, **parsed.except(:compatibility_body), actor: current_user,
|
||||
require_compatibility: true)
|
||||
return head :ok
|
||||
end
|
||||
|
||||
return render_unprocessable_entity field: :title if title.include?(':')
|
||||
description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
|
||||
unless description
|
||||
return render_unprocessable_entity field: :title if group
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
tag = description.tag
|
||||
tag_name = tag.tag_name(locale.language_code)
|
||||
return render_unprocessable_entity field: :title unless tag_name
|
||||
return render_unprocessable_entity field: :title if tag_name.name.include?(':')
|
||||
|
||||
base_revision_id = params[:base_revision_id].presence
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
page.lock!
|
||||
|
||||
old_title = page.title
|
||||
|
||||
tag = page.tag_name.tag
|
||||
|
||||
if tag && title != old_title
|
||||
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
|
||||
|
||||
page.tag_name.update!(name: title) if title != old_title
|
||||
tag_name.update!(name: title) if title != old_title
|
||||
|
||||
message = params[:message].presence
|
||||
Wiki::Commit.content!(page:,
|
||||
body:,
|
||||
created_user: current_user,
|
||||
message:,
|
||||
base_revision_id:)
|
||||
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
|
||||
if tag && title != old_title
|
||||
if title != old_title
|
||||
tag.reload
|
||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
end
|
||||
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_validation_error e.record
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
render_record_not_unique
|
||||
rescue TagGroupMarkdownParser::InvalidDocument => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def changes
|
||||
locale = resolve_locale!
|
||||
|
||||
id = params[:id].presence
|
||||
description = id && TagDescription.find_by(id:)
|
||||
description = id && TagDescription.find_by(id:, language_code: locale.language_code)
|
||||
if description
|
||||
return render json: description_changes(TagDescription.where(id: description.id))
|
||||
return render json: description_changes(locale, TagDescription.where(id: description.id))
|
||||
end
|
||||
|
||||
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
|
||||
revisions = WikiRevision.joins(wiki_page: :tag_name)
|
||||
.where(wiki_page_id: pages.select(:id))
|
||||
.includes(:created_user, wiki_page: { tag_name: :tag })
|
||||
.order(id: :desc).limit(200)
|
||||
json = revisions.map { |rev|
|
||||
{ revision_id: rev.id,
|
||||
pred: rev.base_revision_id,
|
||||
succ: nil,
|
||||
wiki_page: { id: rev.wiki_page_id,
|
||||
title: rev.wiki_page.title,
|
||||
deprecated_at: rev.wiki_page.deprecated_at },
|
||||
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
||||
kind: rev.kind,
|
||||
message: rev.message,
|
||||
timestamp: rev.created_at }
|
||||
}
|
||||
tag_group = id && TagGroup.find_by(id:)
|
||||
if tag_group
|
||||
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
||||
end
|
||||
|
||||
json = []
|
||||
unless id
|
||||
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
||||
json.sort_by! { |row| -row[:revision_id] }
|
||||
json.concat(tag_group_changes(TagGroupVersion.all))
|
||||
json.concat(description_changes(locale,
|
||||
TagDescription.where(language_code: locale.language_code)))
|
||||
json = json.each_with_index.sort_by do |row, index|
|
||||
[-row.fetch(:timestamp).to_f, index]
|
||||
end.map(&:first)
|
||||
end
|
||||
render json: json.first(200)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_wiki_page_or_404 page
|
||||
return head :not_found unless page
|
||||
|
||||
rev = find_revision(page)
|
||||
return head :not_found unless rev
|
||||
|
||||
if rev.redirect?
|
||||
return (
|
||||
redirect_to wiki_page_by_title_path(title: rev.redirect_page.title),
|
||||
status: :moved_permanently)
|
||||
def render_tag_group locale, group
|
||||
version = if params[:version].present?
|
||||
group_version(group, params[:version])
|
||||
else
|
||||
current_group_version(group)
|
||||
end
|
||||
return head :not_found unless version
|
||||
|
||||
body = rev.body
|
||||
revision_id = rev.id
|
||||
pred = page.pred_revision_id(revision_id)
|
||||
succ = page.succ_revision_id(revision_id)
|
||||
updated_at = rev.created_at
|
||||
|
||||
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
|
||||
representation =
|
||||
if params[:version].present?
|
||||
TagGroupRepr.historical(locale, version)
|
||||
else
|
||||
TagGroupRepr.one(locale, group)
|
||||
end
|
||||
render_group_representation(representation, version)
|
||||
end
|
||||
|
||||
def render_wiki_page_by_tag_description description
|
||||
def render_group_version locale, version
|
||||
representation = TagGroupRepr.historical(locale, version)
|
||||
TagGroupMarkdownRenderer.render(representation)
|
||||
end
|
||||
|
||||
def render_group_representation representation, version, body: nil, status: :ok
|
||||
body ||= TagGroupMarkdownRenderer.render(representation)
|
||||
versions = TagGroupVersion.where(tag_group_id: version.tag_group_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
|
||||
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
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def current_group_version group
|
||||
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||
end
|
||||
|
||||
def group_version group, version_id
|
||||
TagGroupVersion.find_by(tag_group_id: group.id, id: version_id)
|
||||
end
|
||||
|
||||
def wiki_diff from_body, to_body
|
||||
diffs = Diff::LCS.sdiff(from_body.to_s.lines, to_body.to_s.lines)
|
||||
diffs.map { |change|
|
||||
case change.action
|
||||
when ?=
|
||||
{ type: 'context', content: change.old_element }
|
||||
when ?!
|
||||
[{ type: 'removed', content: change.old_element },
|
||||
{ type: 'added', content: change.new_element }]
|
||||
when ?+
|
||||
{ type: 'added', content: change.new_element }
|
||||
when ?-
|
||||
{ type: 'removed', content: change.old_element }
|
||||
end
|
||||
}.flatten.compact
|
||||
end
|
||||
|
||||
def tag_group_changes versions
|
||||
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
|
||||
groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id)
|
||||
predecessors = TagGroupVersion.where(tag_group_id: groups.keys,
|
||||
version_no: rows.map { _1.version_no - 1 })
|
||||
.pluck(:tag_group_id, :version_no, :id)
|
||||
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
|
||||
rows.map do |version|
|
||||
group = groups.fetch(version.tag_group_id)
|
||||
user = version.created_by_user
|
||||
{ revision_id: version.id,
|
||||
pred: predecessors[[group.id, version.version_no - 1]],
|
||||
succ: nil,
|
||||
wiki_page: { id: group.id, title: "#{ TagGroupRepr::PREFIX }#{ version.name }",
|
||||
deprecated_at: nil },
|
||||
user: user && { id: user.id, name: user.name },
|
||||
kind: 'content', message: nil, timestamp: version.created_at }
|
||||
end
|
||||
end
|
||||
|
||||
def render_wiki_page_by_tag_description locale, description
|
||||
versions = description_versions(description)
|
||||
historical = params[:version].present?
|
||||
rev = historical ? versions.find_by(id: params[:version]) :
|
||||
@@ -255,7 +396,7 @@ class WikiPagesController < ApplicationController
|
||||
succ = versions.find_by(version_no: rev.version_no + 1)&.id
|
||||
body = historical ? rev.body : description.body
|
||||
updated_at = historical ? rev.created_at : description.updated_at
|
||||
render json: WikiPageRepr.tag_description(description).merge(
|
||||
render json: WikiPageRepr.tag_description(locale, description).merge(
|
||||
body:, revision_id: rev.id, pred:, succ:, updated_at:)
|
||||
end
|
||||
|
||||
@@ -264,20 +405,14 @@ class WikiPagesController < ApplicationController
|
||||
language_code: description.language_code)
|
||||
end
|
||||
|
||||
def tag_description_by_title title
|
||||
def tag_description_by_title locale, title
|
||||
TagDescription.joins(tag: :tag_names)
|
||||
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
|
||||
.find_by(language_code: locale.language_code,
|
||||
tag_names: { language_code: locale.language_code, name: title })
|
||||
end
|
||||
|
||||
def unmigrated_wiki_pages
|
||||
descriptions = TagDescription.where(language_code: 'ja')
|
||||
WikiPage.joins(:tag_name).where.not(id: descriptions.select(:id))
|
||||
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
||||
descriptions.select(:tag_id))
|
||||
end
|
||||
|
||||
def description_changes descriptions
|
||||
versions = TagDescriptionVersion.where(language_code: 'ja',
|
||||
def description_changes locale, descriptions
|
||||
versions = TagDescriptionVersion.where(language_code: locale.language_code,
|
||||
tag_id: descriptions.select(:tag_id))
|
||||
.order(id: :desc).limit(200).to_a
|
||||
return [] if versions.empty?
|
||||
@@ -286,7 +421,7 @@ class WikiPagesController < ApplicationController
|
||||
.includes(tag: :tag_names).index_by(&:tag_id)
|
||||
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
|
||||
predecessors = TagDescriptionVersion.where(
|
||||
language_code: 'ja', tag_id: by_tag.keys,
|
||||
language_code: locale.language_code, tag_id: by_tag.keys,
|
||||
version_no: versions.map { |version| version.version_no - 1 })
|
||||
.pluck(:tag_id, :version_no, :id)
|
||||
.to_h { |tag_id, version_no, id| [[tag_id, version_no], id] }
|
||||
@@ -296,7 +431,7 @@ class WikiPagesController < ApplicationController
|
||||
{ revision_id: version.id,
|
||||
pred: predecessors[[version.tag_id, version.version_no - 1]],
|
||||
succ: nil,
|
||||
wiki_page: { id: description.id, title: description.tag.name('ja'),
|
||||
wiki_page: { id: description.id, title: description.tag.name(locale.language_code),
|
||||
deprecated_at: description.tag.deprecated_at },
|
||||
user: user && { id: user.id, name: user.name },
|
||||
kind: 'content',
|
||||
@@ -305,14 +440,6 @@ class WikiPagesController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def find_revision page
|
||||
if params[:version].present?
|
||||
page.wiki_revisions.find_by(id: params[:version])
|
||||
else
|
||||
page.current_revision
|
||||
end
|
||||
end
|
||||
|
||||
def render_wiki_conflict err
|
||||
render json: { error: 'conflict', message: err.message }, status: :conflict
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class ExternalTag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
enum :platform, nico: 'nico'
|
||||
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Language < ApplicationRecord
|
||||
has_many :locales, foreign_key: :language_code, primary_key: :code
|
||||
|
||||
enum :code, ja: 'ja'
|
||||
enum :code, ja: 'ja', ain: 'ain'
|
||||
end
|
||||
|
||||
+15
-15
@@ -1,27 +1,27 @@
|
||||
class Locale < ApplicationRecord
|
||||
after_create_commit :generate_tag_names!
|
||||
attr_accessor :certified_tag_names_path
|
||||
|
||||
before_save :lock_tag_creation!, if: :will_generate_tag_names?
|
||||
after_save :generate_tag_names!, if: :needs_tag_names?
|
||||
|
||||
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
||||
belongs_to :script, foreign_key: :script_code, primary_key: :code
|
||||
|
||||
enum :code, ja: 'ja', en: 'en', fr: 'fr'
|
||||
enum :code, ja: 'ja', ain_kana: 'ain-Kana'
|
||||
|
||||
def self.nipponese = Locale.find('ja')
|
||||
|
||||
private
|
||||
|
||||
def generate_tag_names!
|
||||
ApplicationRecord.transaction do
|
||||
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
|
||||
Tag.where.not(id: tag_ids).find_each do
|
||||
TagName.create!(tag_id: _1.id,
|
||||
language_code:,
|
||||
name: TagName.generate_name(self, _1, _1.tag_name('ja')),
|
||||
script_code:,
|
||||
primary_flg: true,
|
||||
auto_generated: true,
|
||||
canonical_id: nil)
|
||||
end
|
||||
end
|
||||
def will_generate_tag_names?
|
||||
deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?)
|
||||
end
|
||||
|
||||
def lock_tag_creation! = SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
|
||||
def needs_tag_names?
|
||||
deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?)
|
||||
end
|
||||
|
||||
def generate_tag_names! = TagNameCompletion.for_locale!(self)
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Script < ApplicationRecord
|
||||
has_many :locales, foreign_key: :script_code, primary_key: :code
|
||||
|
||||
enum :code, Jpan: 'Jpan'
|
||||
enum :code, jpan: 'Jpan', kana: 'Kana'
|
||||
end
|
||||
|
||||
+30
-46
@@ -2,6 +2,8 @@ require 'set'
|
||||
|
||||
|
||||
class Tag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
class NicoTagNormalisationError < ArgumentError
|
||||
;
|
||||
end
|
||||
@@ -51,6 +53,7 @@ class Tag < ApplicationRecord
|
||||
has_many :tag_versions
|
||||
|
||||
has_many :tag_names
|
||||
has_many :tag_descriptions
|
||||
|
||||
attribute :version_no, :integer, default: 1
|
||||
|
||||
@@ -88,7 +91,6 @@ class Tag < ApplicationRecord
|
||||
end
|
||||
end
|
||||
def name(language_code) = tag_name(language_code)&.name
|
||||
def wiki_page = tag_name('ja')&.wiki_page
|
||||
|
||||
def system?
|
||||
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
|
||||
@@ -96,13 +98,19 @@ class Tag < ApplicationRecord
|
||||
|
||||
def deprecated? = deprecated_at?
|
||||
|
||||
def has_wiki = wiki_page.present?
|
||||
def has_description? language_code:
|
||||
if tag_descriptions.loaded?
|
||||
tag_descriptions.any? { _1.language_code == language_code }
|
||||
else
|
||||
tag_descriptions.exists?(language_code:)
|
||||
end
|
||||
end
|
||||
|
||||
def material_id = materials.first&.id
|
||||
|
||||
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
|
||||
|
||||
def snapshot_aliases(language_code)
|
||||
def snapshot_aliases language_code
|
||||
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
|
||||
end
|
||||
|
||||
@@ -251,23 +259,24 @@ class Tag < ApplicationRecord
|
||||
name = name.to_s.strip
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
tn = TagName.find_or_create_by!(language_code:, name:) do
|
||||
_1.script_code = locale.script_code
|
||||
_1.primary_flg = true
|
||||
end
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tn = TagName.lock.find_by(language_code:, name:)
|
||||
next tn.tag if tn
|
||||
|
||||
tag = tn.tag
|
||||
next tag if tag
|
||||
|
||||
tag = Tag.create!(tag_name_id: tn.id, category:)
|
||||
tn.update!(tag:, primary_flg: true, canonical_id: nil, auto_generated: false)
|
||||
generate_tag_names!(locale, tag, 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.association(:tag_names).reset
|
||||
|
||||
tag
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
retry
|
||||
existing = TagName.find_by(language_code:, name:)
|
||||
raise unless existing
|
||||
|
||||
existing.tag
|
||||
end
|
||||
|
||||
def merge_tags! target_tag, source_tags, created_by_user: nil
|
||||
@@ -294,20 +303,18 @@ class Tag < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
source_tag_name = source_tag.tag_name('ja')
|
||||
|
||||
if source_tag_name.wiki_page.present?
|
||||
raise ActiveRecord::RecordInvalid.new(source_tag_name)
|
||||
if source_tag.tag_descriptions.exists?
|
||||
source_tag.errors.add(:base, :invalid)
|
||||
raise ActiveRecord::RecordInvalid, source_tag
|
||||
end
|
||||
|
||||
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
|
||||
source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false,
|
||||
updated_at: Time.current)
|
||||
target_languages = target_tag.tag_names.where(primary_flg: true).pluck(:language_code)
|
||||
source_tag.tag_names.where(language_code: target_languages)
|
||||
.update_all(primary_flg: false, auto_generated: false, updated_at: Time.current)
|
||||
source_tag.tag_names.update_all(tag_id: target_tag.id, updated_at: Time.current)
|
||||
source_tag.destroy!
|
||||
|
||||
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
|
||||
updated_at: Time.current)
|
||||
|
||||
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
||||
end
|
||||
|
||||
@@ -349,29 +356,6 @@ class Tag < ApplicationRecord
|
||||
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_tag_names! locale, tag, base_tag_name
|
||||
ApplicationRecord.transaction do
|
||||
locales =
|
||||
Locale
|
||||
.where(deprecated_at: nil)
|
||||
.where.not(language_code: locale.language_code)
|
||||
.order(:language_code, :created_at, :script_code)
|
||||
.uniq { _1.language_code }
|
||||
locales.each do |locale|
|
||||
language_code = locale.language_code
|
||||
script_code = locale.script_code
|
||||
TagName.create!(tag_id: tag.id,
|
||||
language_code:,
|
||||
name: TagName.generate_name(locale, tag, base_tag_name),
|
||||
script_code:,
|
||||
primary_flg: true,
|
||||
auto_generated: true,
|
||||
canonical_id: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class TagDescription < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
belongs_to :tag
|
||||
end
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class TagDescriptionVersion < ApplicationRecord
|
||||
;
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
class TagGroup < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
before_destroy :delete_current_structure!
|
||||
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
|
||||
has_many :tag_group_tags, dependent: :delete_all
|
||||
has_many :tags, through: :tag_group_tags
|
||||
has_many :tag_group_groups, dependent: :delete_all
|
||||
has_many :child_tag_groups, through: :tag_group_groups
|
||||
has_many :placements, -> { order(:position) },
|
||||
class_name: 'TagGroupPlacement', dependent: :delete_all
|
||||
has_many :versions, -> { order(:version_no) },
|
||||
class_name: 'TagGroupVersion'
|
||||
|
||||
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
|
||||
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
|
||||
|
||||
private
|
||||
|
||||
def delete_current_structure!
|
||||
placements.reorder(position: :desc).each(&:delete)
|
||||
tag_group_tags.delete_all
|
||||
tag_group_groups.delete_all
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class TagGroupGroup < ApplicationRecord
|
||||
self.primary_key = [:tag_group_id, :child_tag_group_id]
|
||||
|
||||
belongs_to :tag_group
|
||||
belongs_to :child_tag_group, class_name: 'TagGroup'
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class TagGroupPlacement < ApplicationRecord
|
||||
self.primary_key = [:tag_group_id, :position]
|
||||
|
||||
belongs_to :tag_group
|
||||
belongs_to :tag, optional: true
|
||||
belongs_to :child_tag_group, class_name: 'TagGroup', optional: true
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class TagGroupTag < ApplicationRecord
|
||||
self.primary_key = [:tag_group_id, :tag_id]
|
||||
|
||||
belongs_to :tag_group
|
||||
belongs_to :tag
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class TagGroupVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
belongs_to :tag_group
|
||||
|
||||
scope :ordered, -> { order(:version_no) }
|
||||
end
|
||||
@@ -1,14 +1,12 @@
|
||||
class TagName < ApplicationRecord
|
||||
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
||||
belongs_to :tag, optional: true
|
||||
has_one :wiki_page
|
||||
belongs_to :tag
|
||||
|
||||
validates :name, presence: true,
|
||||
length: { maximum: 255 },
|
||||
uniqueness: { scope: :language_code }
|
||||
|
||||
validate :alias_name_must_not_have_prefix
|
||||
validate :alias_must_not_have_wiki_page
|
||||
validate :name_must_be_sanitised
|
||||
validate :name_mustnt_start_with_nico
|
||||
validate :generated_name_must_be_reserved
|
||||
@@ -21,22 +19,37 @@ class TagName < ApplicationRecord
|
||||
|
||||
tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name)
|
||||
|
||||
primary_names = TagName.where(
|
||||
language_code: locale.language_code, tag_id: tns.values.map(&:tag_id),
|
||||
primary_flg: true).pluck(:tag_id, :name).to_h
|
||||
|
||||
names.map { |name|
|
||||
tag_name = tns[name]
|
||||
if !tag_name || tag_name.primary? || tag_name.tag_id.nil?
|
||||
if !tag_name || tag_name.primary?
|
||||
name
|
||||
else
|
||||
TagName.find_by(language_code: locale.language_code,
|
||||
tag_id: tag_name.tag_id,
|
||||
primary_flg: true)&.name || name
|
||||
primary_names[tag_name.tag_id] || name
|
||||
end
|
||||
}.uniq
|
||||
end
|
||||
|
||||
def self.generate_name locale, tag, base_tag_name
|
||||
# TODO: 言語ごとの自動命名ロジック完成したら書く.
|
||||
|
||||
"Tag_##{ tag.id }"
|
||||
fallback = "Tag_##{ tag.id }"
|
||||
name =
|
||||
case [locale.language_code, base_tag_name&.language_code]
|
||||
when ['ain', 'ja']
|
||||
TagNameTranscription.ainu(base_tag_name.name)
|
||||
when ['ja', 'ain']
|
||||
base_tag_name.name
|
||||
else
|
||||
return fallback
|
||||
end
|
||||
candidate = new(tag:, name:, language_code: locale.language_code,
|
||||
script_code: locale.script_code,
|
||||
primary_flg: true, auto_generated: true)
|
||||
candidate.valid? ? name : fallback
|
||||
rescue TagNameTranscription::Unconvertible
|
||||
fallback
|
||||
end
|
||||
|
||||
private
|
||||
@@ -53,13 +66,9 @@ class TagName < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def alias_must_not_have_wiki_page
|
||||
if !(primary?) && wiki_page
|
||||
errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.'
|
||||
end
|
||||
end
|
||||
|
||||
def name_must_be_sanitised
|
||||
return if auto_generated && name == "Tag_##{ tag_id }"
|
||||
|
||||
if name? && name != TagNameSanitisationRule.sanitise(name)
|
||||
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
||||
end
|
||||
|
||||
@@ -16,37 +16,36 @@ class TagNameSanitisationRule < ApplicationRecord
|
||||
|
||||
def apply!
|
||||
Language.find_each do |language|
|
||||
TagName.where(language:).find_each do |tn|
|
||||
name = sanitise(tn.name)
|
||||
next if name == tn.name
|
||||
|
||||
TagName.where(language:).find_each do |candidate|
|
||||
TagName.transaction do
|
||||
tn = TagName.find_by(id: candidate.id)
|
||||
next unless tn
|
||||
|
||||
name = sanitise(tn.name)
|
||||
next if name == tn.name
|
||||
|
||||
existing_tn = TagName.find_by(language:, name:)
|
||||
if existing_tn
|
||||
unless existing_tn.primary_flg
|
||||
existing_tn = TagName.find_by!(language:,
|
||||
tag_id: existing_tn.tag_id,
|
||||
primary_flg: true)
|
||||
source_tag = tn.tag
|
||||
target_tag = existing_tn ? existing_tn.tag : source_tag
|
||||
[source_tag, target_tag].uniq(&:id).sort_by(&:id).each(&:lock!)
|
||||
TagVersioning.ensure_snapshot!(source_tag, created_by_user: nil)
|
||||
|
||||
if existing_tn && target_tag != source_tag
|
||||
Tag.merge_tags!(target_tag, source_tag)
|
||||
tn.reload.destroy!
|
||||
elsif existing_tn
|
||||
if tn.primary?
|
||||
existing_tn.destroy!
|
||||
tn.update_columns(name:, updated_at: Time.current)
|
||||
else
|
||||
tn.destroy!
|
||||
end
|
||||
next if existing_tn.id == tn.id
|
||||
|
||||
existing_tag = existing_tn.tag
|
||||
source_tag = tn.tag
|
||||
|
||||
if existing_tag
|
||||
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
|
||||
elsif source_tag
|
||||
tn.update_columns(tag_id: nil, updated_at: Time.current)
|
||||
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
|
||||
existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current)
|
||||
end
|
||||
tn.destroy!
|
||||
|
||||
next
|
||||
else
|
||||
# TagName 側の自動サニタイズを回避
|
||||
tn.update_columns(name:, updated_at: Time.current)
|
||||
end
|
||||
|
||||
# TagName 側の自動サニタイズを回避
|
||||
tn.update_columns(name:, updated_at: Time.current)
|
||||
TagVersioning.record!(target_tag, event_type: :update, created_by_user: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,10 +13,10 @@ class User < ApplicationRecord
|
||||
has_many :ip_addresses, through: :user_ips
|
||||
has_many :user_post_views, dependent: :destroy
|
||||
has_many :viewed_posts, through: :user_post_views, source: :post
|
||||
has_many :created_wiki_pages,
|
||||
class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify
|
||||
has_many :updated_wiki_pages,
|
||||
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
|
||||
has_many :created_tag_descriptions, class_name: 'TagDescription',
|
||||
foreign_key: :created_by_user_id, dependent: :nullify
|
||||
has_many :updated_tag_descriptions, class_name: 'TagDescription',
|
||||
foreign_key: :updated_by_user_id, dependent: :nullify
|
||||
|
||||
def viewed?(post) = user_post_views.exists?(post_id: post.id)
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
class WikiLine < ApplicationRecord
|
||||
has_many :wiki_revision_lines, dependent: :restrict_with_exception
|
||||
|
||||
validates :sha256, presence: true, uniqueness: true, length: { is: 64 }
|
||||
validates :body, presence: true
|
||||
|
||||
def self.upsert_by_body! body
|
||||
sha = Digest::SHA256.hexdigest(body)
|
||||
now = Time.current
|
||||
|
||||
upsert(sha256: sha, body:, created_at: now, updated_at: now)
|
||||
|
||||
find_by!(sha256: sha)
|
||||
end
|
||||
end
|
||||
@@ -1,58 +0,0 @@
|
||||
require 'set'
|
||||
|
||||
|
||||
class WikiPage < ApplicationRecord
|
||||
include MyDiscard
|
||||
|
||||
has_many :wiki_revisions, dependent: :destroy
|
||||
belongs_to :created_user, class_name: 'User'
|
||||
belongs_to :updated_user, class_name: 'User'
|
||||
|
||||
has_many :redirected_from_revisions,
|
||||
class_name: 'WikiRevision',
|
||||
foreign_key: :redirect_page_id,
|
||||
dependent: :nullify
|
||||
|
||||
has_many :wiki_versions
|
||||
|
||||
attribute :version_no, :integer, default: 1
|
||||
|
||||
belongs_to :tag_name
|
||||
validates :tag_name, presence: true
|
||||
validates :body, presence: true
|
||||
|
||||
def title = tag_name.name
|
||||
def deprecated_at = tag_name.tag&.deprecated_at
|
||||
|
||||
def title= val
|
||||
(self.tag_name ||= build_tag_name).name = val
|
||||
end
|
||||
|
||||
def current_revision = wiki_revisions.order(id: :desc).first
|
||||
|
||||
def resolve_redirect limit: 10
|
||||
page = self
|
||||
visited = Set.new
|
||||
|
||||
limit.times do
|
||||
return page if visited.include?(page.id)
|
||||
|
||||
visited.add(page.id)
|
||||
|
||||
rev = page.current_revision
|
||||
return page if !(rev&.redirect?) || !(rev.redirect_page)
|
||||
|
||||
page = rev.redirect_page
|
||||
end
|
||||
|
||||
page
|
||||
end
|
||||
|
||||
def pred_revision_id revision_id
|
||||
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
|
||||
end
|
||||
|
||||
def succ_revision_id revision_id
|
||||
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
|
||||
end
|
||||
end
|
||||
@@ -1,55 +0,0 @@
|
||||
class WikiRevision < ApplicationRecord
|
||||
belongs_to :wiki_page
|
||||
belongs_to :base_revision, class_name: 'WikiRevision', optional: true
|
||||
belongs_to :created_user, class_name: 'User'
|
||||
belongs_to :redirect_page, class_name: 'WikiPage', optional: true
|
||||
|
||||
has_many :wiki_revision_lines, dependent: :delete_all
|
||||
has_many :wiki_lines, through: :wiki_revision_lines
|
||||
|
||||
enum :kind, content: 0, redirect: 1
|
||||
|
||||
validates :kind, presence: true
|
||||
validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validates :tree_sha256, length: { is: 64 }, allow_nil: true
|
||||
|
||||
validate :kind_consistency
|
||||
|
||||
def body
|
||||
return unless content?
|
||||
|
||||
wiki_revision_lines
|
||||
.includes(:wiki_line)
|
||||
.order(:position)
|
||||
.map { |rev| rev.wiki_line.body }
|
||||
.join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def kind_consistency
|
||||
if content?
|
||||
if tree_sha256.blank?
|
||||
errors.add(:tree_sha256, '種類がページの場合は必須です.')
|
||||
end
|
||||
|
||||
if redirect_page_id.present?
|
||||
errors.add(:redirect_page_id, '種類がページの場合は空である必要があります.')
|
||||
end
|
||||
end
|
||||
|
||||
if redirect?
|
||||
if redirect_page_id.blank?
|
||||
errors.add(:redirect_page_id, '種類がリダイレクトの場合は必須です.')
|
||||
end
|
||||
|
||||
if tree_sha256.present?
|
||||
errors.add(:tree_sha256, '種類がリダイレクトの場合は空である必要があります.')
|
||||
end
|
||||
|
||||
if lines_count.to_i > 0
|
||||
errors.add(:lines_count, '種類がリダイレクトの場合は 0 である必要があります.')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
class WikiRevisionLine < ApplicationRecord
|
||||
belongs_to :wiki_revision
|
||||
belongs_to :wiki_line
|
||||
|
||||
validates :position, presence: true,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validates :position, uniqueness: { scope: :wiki_revision_id }
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
class WikiVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
belongs_to :wiki_page
|
||||
|
||||
validates :title, presence: true
|
||||
validates :body, presence: true
|
||||
end
|
||||
@@ -0,0 +1,205 @@
|
||||
module TagGroupRepr
|
||||
PREFIX = 'タググループ:'
|
||||
|
||||
module_function
|
||||
|
||||
def name_from_title title
|
||||
prefix = [PREFIX, 'tag_group:'].find { title.downcase.start_with?(_1) }
|
||||
title[prefix.length..] if prefix
|
||||
end
|
||||
|
||||
def many locale, groups
|
||||
ids = groups.map(&:id)
|
||||
return [] if ids.empty?
|
||||
|
||||
tag_members = TagGroupTag.where(tag_group_id: ids)
|
||||
.group_by(&:tag_group_id)
|
||||
group_members = TagGroupGroup.where(tag_group_id: ids)
|
||||
.group_by(&:tag_group_id)
|
||||
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
|
||||
.group_by(&:tag_group_id)
|
||||
|
||||
target_ids =
|
||||
tag_members.values.flatten.map(&:tag_id) +
|
||||
placements.values.flatten.filter_map(&:tag_id)
|
||||
child_ids =
|
||||
group_members.values.flatten.map(&:child_tag_group_id) +
|
||||
placements.values.flatten.filter_map(&:child_tag_group_id)
|
||||
tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id)
|
||||
names =
|
||||
TagName
|
||||
.where(tag_id: target_ids.uniq,
|
||||
language_code: locale.language_code,
|
||||
primary_flg: true)
|
||||
.pluck(:tag_id, :name)
|
||||
.to_h
|
||||
child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id)
|
||||
|
||||
groups.map do |group|
|
||||
group_json(group,
|
||||
tag_members.fetch(group.id, []),
|
||||
group_members.fetch(group.id, []),
|
||||
placements.fetch(group.id, []), tags, names, child_groups)
|
||||
end
|
||||
end
|
||||
|
||||
def one locale, group
|
||||
many(locale, [group]).first
|
||||
end
|
||||
|
||||
def tag_group wiki_page
|
||||
{ 'id' => wiki_page.id,
|
||||
'title' => "#{ PREFIX }#{ wiki_page.name }",
|
||||
'deprecated_at' => nil,
|
||||
'created_at' => wiki_page.created_at,
|
||||
'updated_at' => wiki_page.updated_at }
|
||||
end
|
||||
|
||||
def current_version group
|
||||
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||
end
|
||||
|
||||
def historical locale, version
|
||||
members = Array(version.members)
|
||||
placements = Array(version.placements)
|
||||
tag_references = members.filter_map { |member| member['tag_id'] } +
|
||||
collect_ids(placements, 'tag_id')
|
||||
group_references = members.filter_map { |member| member['tag_group_id'] } +
|
||||
collect_ids(placements, 'tag_group_id')
|
||||
tag_versions = historical_tag_versions(tag_references)
|
||||
group_versions = historical_group_versions(group_references)
|
||||
tag_version_numbers = members.filter_map do |member|
|
||||
next unless member['tag_id']
|
||||
|
||||
[member.fetch('tag_id'), member.fetch('version_no')]
|
||||
end.to_h
|
||||
group_version_numbers = members.filter_map do |member|
|
||||
next unless member['tag_group_id']
|
||||
|
||||
[member.fetch('tag_group_id'), member.fetch('version_no')]
|
||||
end.to_h
|
||||
historical_members = members.map do |member|
|
||||
historical_member(locale, member, tag_versions, group_versions)
|
||||
end
|
||||
historical_placements = placements.map do |node|
|
||||
historical_placement(locale, node, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers)
|
||||
end
|
||||
{ 'id' => version.tag_group_id,
|
||||
'name' => version.name,
|
||||
'description' => version.description,
|
||||
'version_no' => version.version_no,
|
||||
'members' => historical_members,
|
||||
'placements' => historical_placements,
|
||||
'created_at' => version.created_at,
|
||||
'updated_at' => version.created_at,
|
||||
'created_by_user' => user_json(version.created_by_user),
|
||||
'updated_by_user' => user_json(version.created_by_user) }
|
||||
end
|
||||
|
||||
def group_json group, tag_members, group_members, rows, tags, names, child_groups
|
||||
tree = placement_tree(rows, tags, names, child_groups)
|
||||
members = tag_members.sort_by(&:tag_id).map do |member|
|
||||
{ 'tag' => tag_json(tags.fetch(member.tag_id), names[member.tag_id]) }
|
||||
end
|
||||
members.concat(group_members.sort_by(&:child_tag_group_id).map do |member|
|
||||
{ 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) }
|
||||
end)
|
||||
{ 'id' => group.id,
|
||||
'name' => group.name,
|
||||
'description' => group.description,
|
||||
'version_no' => group.version_no,
|
||||
'members' => members,
|
||||
'placements' => tree,
|
||||
'created_at' => group.created_at,
|
||||
'updated_at' => group.updated_at,
|
||||
'created_by_user' => user_json(group.created_by_user),
|
||||
'updated_by_user' => user_json(group.updated_by_user) }
|
||||
end
|
||||
|
||||
def user_json user
|
||||
UserRepr.base(user) if user
|
||||
end
|
||||
|
||||
def placement_tree rows, tags, names, child_groups
|
||||
by_parent = rows.group_by(&:parent_position)
|
||||
build = lambda do |parent|
|
||||
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
|
||||
node = if row.tag_id
|
||||
{ 'tag' => tag_json(tags.fetch(row.tag_id), names[row.tag_id]) }
|
||||
elsif row.child_tag_group_id
|
||||
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
|
||||
else
|
||||
{ 'section_name' => row.section_name, 'description' => row.description }
|
||||
end
|
||||
node.merge('children' => build.call(row.position))
|
||||
end
|
||||
end
|
||||
build.call(nil)
|
||||
end
|
||||
|
||||
def tag_json tag, name
|
||||
{ 'id' => tag.id, 'name' => name, 'category' => tag.category }
|
||||
end
|
||||
|
||||
def group_json_reference group
|
||||
{ 'id' => group.id, 'name' => group.name }
|
||||
end
|
||||
|
||||
def collect_ids nodes, key
|
||||
nodes.flat_map do |node|
|
||||
[node[key], *collect_ids(Array(node['children']), key)].compact
|
||||
end
|
||||
end
|
||||
|
||||
def historical_tag_versions ids
|
||||
TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version|
|
||||
[version.tag_id, version.version_no]
|
||||
end
|
||||
end
|
||||
|
||||
def historical_group_versions ids
|
||||
TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version|
|
||||
[version.tag_group_id, version.version_no]
|
||||
end
|
||||
end
|
||||
|
||||
def historical_member locale, member, tag_versions, group_versions
|
||||
if member['tag_id']
|
||||
id = member.fetch('tag_id')
|
||||
version = tag_versions.fetch([id, member.fetch('version_no')])
|
||||
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
|
||||
'category' => version.category } }
|
||||
else
|
||||
id = member.fetch('tag_group_id')
|
||||
version = group_versions.fetch([id, member.fetch('version_no')])
|
||||
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
|
||||
end
|
||||
end
|
||||
|
||||
def historical_placement locale, node, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers
|
||||
result = if node['tag_id']
|
||||
id = node.fetch('tag_id')
|
||||
version_no = tag_version_numbers.fetch(id)
|
||||
version = tag_versions.fetch([id, version_no])
|
||||
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
|
||||
'category' => version.category } }
|
||||
elsif node['tag_group_id']
|
||||
id = node.fetch('tag_group_id')
|
||||
version_no = group_version_numbers.fetch(id)
|
||||
version = group_versions.fetch([id, version_no])
|
||||
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
|
||||
else
|
||||
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
|
||||
end
|
||||
result.merge('children' => Array(node['children']).map do |child|
|
||||
historical_placement(locale, child, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers)
|
||||
end)
|
||||
end
|
||||
|
||||
def version_name locale, version
|
||||
version.names[locale.language_code]&.fetch('name')
|
||||
end
|
||||
end
|
||||
@@ -3,21 +3,28 @@
|
||||
|
||||
module TagRepr
|
||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
||||
methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze
|
||||
methods: [:material_id, :has_deerjikists] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def base tag, language_code: 'ja'
|
||||
tag.as_json(BASE).merge(name: name_for(tag, language_code),
|
||||
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||
name: name_for(tag, language_code),
|
||||
aliases: tag.snapshot_aliases(language_code),
|
||||
parents: tag.parents.map { inline(_1, language_code:) })
|
||||
end
|
||||
|
||||
def inline tag, language_code: 'ja'
|
||||
tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: [])
|
||||
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||
name: name_for(tag, language_code), aliases: [], parents: [])
|
||||
end
|
||||
|
||||
def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) }
|
||||
def many tags, language_code: 'ja'
|
||||
records = tags.to_a
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records:, associations: [:tag_descriptions, { parents: :tag_descriptions }]).call
|
||||
records.map { |tag| base(tag, language_code:) }
|
||||
end
|
||||
|
||||
def name_for tag, language_code
|
||||
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
|
||||
|
||||
module WikiPageRepr
|
||||
BASE = { methods: [:title, :deprecated_at] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def base wiki_page
|
||||
wiki_page.as_json(BASE)
|
||||
end
|
||||
|
||||
def tag_description description
|
||||
def tag_description locale, description
|
||||
tag = description.tag
|
||||
|
||||
{ 'id' => description.id,
|
||||
'tag_name_id' => tag.tag_name('ja')&.id,
|
||||
'resource_type' => 'tag_description',
|
||||
'tag_id' => tag.id,
|
||||
'language_code' => description.language_code,
|
||||
'tag_name_id' => tag.tag_name(locale.language_code)&.id,
|
||||
'body' => description.body,
|
||||
'created_user_id' => description.created_by_user_id,
|
||||
'updated_user_id' => description.updated_by_user_id,
|
||||
@@ -21,11 +19,31 @@ module WikiPageRepr
|
||||
'updated_at' => description.updated_at,
|
||||
'discarded_at' => nil,
|
||||
'version_no' => description.version_no,
|
||||
'title' => tag.name('ja'),
|
||||
'title' => tag.name(locale.language_code),
|
||||
'deprecated_at' => tag.deprecated_at }
|
||||
end
|
||||
|
||||
def many wiki_pages
|
||||
wiki_pages.map { |p| base(p) }
|
||||
def tag_group tag_group
|
||||
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||
end
|
||||
|
||||
def autolink_tag locale, tag_id:, description_id:, name:
|
||||
{ resource_type: 'tag_description',
|
||||
resource_id: description_id,
|
||||
tag_id:,
|
||||
tag_description_id: description_id,
|
||||
name:,
|
||||
language_code: locale.language_code,
|
||||
locale_code: locale.code_before_type_cast }
|
||||
end
|
||||
|
||||
def autolink_group locale, id:, name:, language:
|
||||
{ resource_type: 'tag_group',
|
||||
resource_id: id,
|
||||
tag_id: nil,
|
||||
tag_description_id: nil,
|
||||
name: "#{ TagGroupRepr::PREFIX }#{ name }",
|
||||
language_code: language,
|
||||
locale_code: locale.code_before_type_cast }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
require 'json'
|
||||
require 'pathname'
|
||||
|
||||
|
||||
class CertifiedTagNameTranslations
|
||||
FORMAT = 'btrc-hub.certified-tag-name-translations.v1'.freeze
|
||||
|
||||
class Error < ArgumentError
|
||||
;
|
||||
end
|
||||
|
||||
def self.load! path, locale:
|
||||
new(path, locale:).load!
|
||||
end
|
||||
|
||||
def initialize path, locale:
|
||||
@path = path
|
||||
@locale = locale
|
||||
end
|
||||
|
||||
def load!
|
||||
document = read_document
|
||||
unless document.is_a?(Hash) && document['format'] == FORMAT
|
||||
fail!('invalid format or version')
|
||||
end
|
||||
|
||||
source_code = document['source_locale']
|
||||
target_code = document['target_locale']
|
||||
unless target_code.is_a?(String) && target_code == Locale.codes.fetch(@locale.code)
|
||||
fail!("target_locale #{ target_code.inspect } does not match the new Locale")
|
||||
end
|
||||
unless source_code.is_a?(String) && Locale.codes.value?(source_code)
|
||||
fail!("invalid source_locale #{ source_code.inspect }")
|
||||
end
|
||||
source_locale = Locale.find_by(code: source_code)
|
||||
fail!("source Locale #{ source_code.inspect } does not exist") unless source_locale
|
||||
fail!('source_locale and target_locale must differ') if source_code == target_code
|
||||
|
||||
entries = validate_entries(document['entries'], source_code, target_code)
|
||||
resolve_tags(entries, source_locale.language_code)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def read_document
|
||||
unless (@path.is_a?(String) && !@path.include?("\0") &&
|
||||
Pathname.new(@path).absolute?)
|
||||
fail!('certified_tag_names_path must be an absolute file path')
|
||||
end
|
||||
fail!('JSON file does not exist or is not a regular file') unless File.file?(@path)
|
||||
|
||||
text = File.read(@path, encoding: Encoding::UTF_8)
|
||||
fail!('JSON file is not valid UTF-8') unless text.valid_encoding?
|
||||
JSON.parse(text)
|
||||
rescue JSON::ParserError, SystemCallError, IOError, EncodingError => error
|
||||
fail!("cannot read JSON: #{ error.message }")
|
||||
end
|
||||
|
||||
def validate_entries entries, source_code, target_code
|
||||
fail!('entries must be an array') unless entries.is_a?(Array)
|
||||
|
||||
sources = {}
|
||||
targets = {}
|
||||
entries.each_with_index.map do |entry, index|
|
||||
fail!("entry #{ index + 1 } must be an object") unless entry.is_a?(Hash)
|
||||
source = entry[source_code]
|
||||
target = entry[target_code]
|
||||
unless source.is_a?(String) && source.present? && target.is_a?(String) && target.present?
|
||||
fail!("entry #{ index + 1 }: missing or empty #{ source_code }/#{ target_code } name")
|
||||
end
|
||||
fail!("duplicate source name #{ source.inspect }") if sources.key?(source)
|
||||
fail!("duplicate target name #{ target.inspect }") if targets.key?(target)
|
||||
|
||||
sources[source] = true
|
||||
targets[target] = true
|
||||
[source, target]
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_tags entries, source_language
|
||||
source_names = entries.map(&:first)
|
||||
# SQL may use a case/accent-insensitive collation; the Ruby keys are exact.
|
||||
matches = TagName.where(language_code: source_language, primary_flg: true,
|
||||
name: source_names).pluck(:name, :tag_id).group_by(&:first)
|
||||
translations = {}
|
||||
entries.each do |source, target|
|
||||
rows = matches.fetch(source, [])
|
||||
unless rows.length == 1
|
||||
fail!("source primary name #{ source.inspect } must match exactly one tag")
|
||||
end
|
||||
tag_id = rows.first.last
|
||||
if translations.key?(tag_id)
|
||||
fail!("multiple source primary names resolve to Tag##{ tag_id }: #{ source.inspect }")
|
||||
end
|
||||
translations[tag_id] = target
|
||||
end
|
||||
translations
|
||||
end
|
||||
|
||||
def fail! message
|
||||
raise Error, "Certified tag names #{@path.inspect}: #{ message }"
|
||||
end
|
||||
end
|
||||
@@ -95,14 +95,10 @@ class PostCreatePlan
|
||||
def build_default_tag_specs locale, direct_tag_specs
|
||||
default_tag_specs = []
|
||||
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
|
||||
default_tag_specs << {
|
||||
name: TAGME_TAG_NAME,
|
||||
category: :meta }
|
||||
default_tag_specs << { name: TAGME_TAG_NAME, category: :meta }
|
||||
end
|
||||
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) }
|
||||
default_tag_specs << {
|
||||
name: NO_DEERJIKIST_TAG_NAME,
|
||||
category: :meta }
|
||||
default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta }
|
||||
end
|
||||
|
||||
default_tag_specs
|
||||
@@ -117,9 +113,9 @@ class PostCreatePlan
|
||||
end
|
||||
|
||||
def validate_new_tag_spec! locale, spec
|
||||
tag_name = TagName.new(name: spec[:name], language_code: locale.language_code,
|
||||
script_code: locale.script_code, primary_flg: true)
|
||||
tag = Tag.new(category: spec[:category])
|
||||
tag_name = TagName.new(tag:, name: spec[:name], language_code: locale.language_code,
|
||||
script_code: locale.script_code, primary_flg: true)
|
||||
return if tag_name.valid? && tag.valid?
|
||||
|
||||
post = Post.new
|
||||
@@ -140,10 +136,10 @@ class PostCreatePlan
|
||||
end
|
||||
expanded_parent_specs =
|
||||
Tag.expand_parent_tags(existing_snapshot_tags)
|
||||
.reject(&:deprecated?)
|
||||
.map { |tag|
|
||||
{ name: tag.name(locale.language_code),
|
||||
category: tag.category.to_sym }
|
||||
.reject(&:deprecated?)
|
||||
.map { |tag|
|
||||
{ name: tag.name(locale.language_code),
|
||||
category: tag.category.to_sym }
|
||||
}
|
||||
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
|
||||
end
|
||||
@@ -164,9 +160,10 @@ class PostCreatePlan
|
||||
def existing_tags_by_name locale
|
||||
@existing_tags_by_name ||= begin
|
||||
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
|
||||
TagName.where(language_code: locale.language_code, primary_flg: true, name: names)
|
||||
.includes(:tag)
|
||||
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
|
||||
TagName
|
||||
.where(language_code: locale.language_code, primary_flg: true, name: names)
|
||||
.includes(:tag)
|
||||
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
require 'digest'
|
||||
|
||||
|
||||
class SharedResourceIdAllocator
|
||||
SPACES = {
|
||||
'tags' => ['tags', 'external_tags'],
|
||||
'wiki' => ['tag_descriptions', 'tag_groups'],
|
||||
'wiki_history' => ['tag_description_versions', 'tag_group_versions'] }.freeze
|
||||
HISTORY_IDS = {
|
||||
'tags' => [
|
||||
['tag_versions', 'tag_id'],
|
||||
['nico_tag_versions', 'tag_id'],
|
||||
['tag_description_versions', 'tag_id']],
|
||||
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
|
||||
LOCK_TIMEOUT = 10
|
||||
TRANSACTION_LOCKS = ObjectSpace::WeakMap.new
|
||||
|
||||
def self.assign! record
|
||||
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
|
||||
raise ArgumentError, 'Unknown shared ID resource' unless tables
|
||||
|
||||
connection = record.class.connection
|
||||
lock_space!(record.class, space)
|
||||
|
||||
# 明示-tic な Id. 指定は許可する.
|
||||
return if record.id.present?
|
||||
|
||||
next_id = record.class.uncached do
|
||||
historical_max = HISTORY_IDS.fetch(space, []).map { |table, column|
|
||||
quoted_table = connection.quote_table_name(table)
|
||||
quoted_column = connection.quote_column_name(column)
|
||||
connection.select_value("SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i
|
||||
}.max.to_i
|
||||
|
||||
[next_ids(connection, tables).max, historical_max + 1].max
|
||||
end
|
||||
|
||||
record.id = next_id
|
||||
end
|
||||
|
||||
def self.lock_space! record_class, space
|
||||
raise ArgumentError, 'Unknown shared ID resource' unless SPACES.key?(space)
|
||||
|
||||
connection = record_class.connection
|
||||
transaction = record_class.current_transaction
|
||||
unless transaction.open?
|
||||
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
|
||||
end
|
||||
|
||||
database = connection.pool.db_config.database
|
||||
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
|
||||
held_locks = TRANSACTION_LOCKS[transaction] ||= {}
|
||||
return if held_locks.key?(name)
|
||||
|
||||
quoted_name = connection.quote(name)
|
||||
acquired = record_class.uncached do
|
||||
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
|
||||
end
|
||||
unless acquired.to_i == 1
|
||||
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
|
||||
end
|
||||
held_locks[name] = true
|
||||
|
||||
release = lambda do
|
||||
held_locks.delete(name)
|
||||
result = record_class.uncached do
|
||||
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
|
||||
end
|
||||
unless result.to_i == 1
|
||||
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
|
||||
end
|
||||
end
|
||||
begin
|
||||
transaction.after_commit(&release)
|
||||
transaction.after_rollback(&release)
|
||||
rescue StandardError
|
||||
release.call
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def self.next_ids connection, tables
|
||||
expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i
|
||||
begin
|
||||
connection.execute('SET SESSION information_schema_stats_expiry = 0')
|
||||
quoted_tables = tables.map { connection.quote(_1) }.join(', ')
|
||||
rows = connection.select_all(<<~SQL).to_a
|
||||
SELECT TABLE_NAME, ENGINE, AUTO_INCREMENT
|
||||
FROM information_schema.tables
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (#{ quoted_tables })
|
||||
SQL
|
||||
persistent = rows.all? do |row|
|
||||
row['ENGINE'] == 'InnoDB' && row['AUTO_INCREMENT'].to_i.positive?
|
||||
end
|
||||
unless rows.size == tables.size && persistent
|
||||
raise ActiveRecord::ActiveRecordError, 'Persistent InnoDB counters are required'
|
||||
end
|
||||
rows.map { _1.fetch('AUTO_INCREMENT').to_i }
|
||||
ensure
|
||||
connection.execute("SET SESSION information_schema_stats_expiry = #{ expiry }")
|
||||
end
|
||||
end
|
||||
private_class_method :next_ids
|
||||
end
|
||||
@@ -1,54 +1,69 @@
|
||||
class TagDescriptionOperation
|
||||
LANGUAGE_CODE = 'ja'
|
||||
class Conflict < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.create!(tag:, body:, created_by_user:, message: nil)
|
||||
def self.create! locale, tag:, body:, created_by_user:, message: nil
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
|
||||
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
||||
tag_id: tag.id, language_code: locale.language_code)
|
||||
|
||||
page = Wiki::CompatibilityWriter.create!(
|
||||
tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:)
|
||||
revision = page.wiki_revisions.order(id: :desc).first
|
||||
record_version!(tag:, revision:, body:, user: created_by_user,
|
||||
description: nil)
|
||||
page
|
||||
description = TagDescription.new(tag:, language_code: locale.language_code)
|
||||
body = normalise_body!(description, body)
|
||||
record_version!(description:, body:, user: created_by_user)
|
||||
|
||||
description
|
||||
end
|
||||
end
|
||||
|
||||
def self.update!(tag:, body:, updated_by_user:, message: nil,
|
||||
base_revision_id: nil)
|
||||
def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
|
||||
ApplicationRecord.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
tag.lock!
|
||||
description = TagDescription.lock.find_by!(
|
||||
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
||||
page = WikiPage.lock.find(description.id)
|
||||
revision = Wiki::CompatibilityWriter.update!(
|
||||
page:, body:, updated_by_user:, message:,
|
||||
base_revision_id:)
|
||||
next unless revision
|
||||
tag_id: tag.id, language_code: locale.language_code)
|
||||
body = normalise_body!(description, body)
|
||||
latest = TagDescriptionVersion.find_by(
|
||||
tag_id: tag.id, language_code: locale.language_code,
|
||||
version_no: description.version_no)
|
||||
if base_revision_id.present? && latest&.id != base_revision_id.to_i
|
||||
raise Conflict, '競合が発生してゐます.'
|
||||
end
|
||||
next if latest && normalise_body(description.body) == body
|
||||
|
||||
record_version!(tag:, revision:, body: revision.body,
|
||||
user: updated_by_user, description:)
|
||||
revision
|
||||
record_version!(description:, body:, user: updated_by_user)
|
||||
end
|
||||
end
|
||||
|
||||
def self.record_version! tag:, revision:, body:, user:, description:
|
||||
version_no = description ? description.version_no + 1 : 1
|
||||
def self.record_version! description:, body:, user:
|
||||
creating = description.new_record?
|
||||
version_no = creating ? 1 : description.version_no + 1
|
||||
description.created_by_user_id = user.id if creating
|
||||
description.update!(body:, version_no:, updated_by_user_id: user.id)
|
||||
TagDescriptionVersion.create!(
|
||||
id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE,
|
||||
version_no:, event_type: description ? 'update' : 'create', body:,
|
||||
created_by_user_id: user.id, created_at: revision.created_at)
|
||||
if description
|
||||
description.update!(body:, version_no:, updated_by_user_id: user.id)
|
||||
else
|
||||
TagDescription.create!(
|
||||
id: revision.wiki_page_id, tag_id: tag.id,
|
||||
language_code: LANGUAGE_CODE, version_no:, body:,
|
||||
created_by_user_id: user.id, updated_by_user_id: user.id)
|
||||
end
|
||||
tag_id: description.tag_id, language_code: description.language_code,
|
||||
version_no:, event_type: creating ? 'create' : 'update', body:,
|
||||
source_language_code: description.source_language_code,
|
||||
source_version_no: description.source_version_no,
|
||||
created_by_user_id: user.id, created_at: Time.current)
|
||||
end
|
||||
|
||||
private_class_method :record_version!
|
||||
|
||||
def self.normalise_body body
|
||||
body.to_s.gsub(/\r\n?/, "\n")
|
||||
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
|
||||
.sub(/\n+\z/, '')
|
||||
end
|
||||
private_class_method :normalise_body
|
||||
|
||||
def self.normalise_body! description, body
|
||||
normalised = normalise_body(body)
|
||||
return normalised if normalised.present?
|
||||
|
||||
description.errors.add(:body, :blank)
|
||||
raise ActiveRecord::RecordInvalid, description
|
||||
end
|
||||
private_class_method :normalise_body!
|
||||
end
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
require 'kramdown'
|
||||
|
||||
class TagGroupMarkdownParser
|
||||
PREFIX = TagGroupRepr::PREFIX
|
||||
|
||||
class InvalidDocument < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.parse(locale, body:, group: nil)
|
||||
new(body:, group:).parse(locale)
|
||||
end
|
||||
|
||||
def initialize(body:, group:)
|
||||
@body = body.to_s.gsub(/\r\n?/, "\n")
|
||||
@group = group
|
||||
end
|
||||
|
||||
def parse locale
|
||||
parse_body = @body.gsub(/([^\n])\n([\x23]{1,6}[ \t]+\S)/, "\\1\n\n\\2")
|
||||
root = Kramdown::Document.new(parse_body, input: 'GFM', auto_ids: false).root
|
||||
placements = []
|
||||
description_parts = []
|
||||
headings = []
|
||||
|
||||
root.children.each do |node|
|
||||
case node.type
|
||||
when :blank
|
||||
next
|
||||
when :header
|
||||
level = node.options.fetch(:level)
|
||||
name = inline_text(node).strip
|
||||
invalid!('Section 名は必須です.') if name.blank?
|
||||
|
||||
headings.pop while headings.any? && headings.last.fetch(:level) >= level
|
||||
parent = headings.last&.fetch(:node)
|
||||
section = { 'section_name' => name, 'description' => nil,
|
||||
'children' => [] }
|
||||
(parent ? parent.fetch('children') : placements) << section
|
||||
headings << { level:, node: section }
|
||||
when :p
|
||||
text = inline_markdown(node).strip
|
||||
next if text.empty?
|
||||
|
||||
if headings.any?
|
||||
section = headings.last.fetch(:node)
|
||||
section['description'] = [section['description'], text].compact.join("\n\n")
|
||||
else
|
||||
description_parts << text
|
||||
end
|
||||
when :ul
|
||||
invalid!('未対応のリスト項目があります.') if node.attr.any?
|
||||
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
|
||||
target.concat(node.children.map { parse_list_item(locale, _1) })
|
||||
else
|
||||
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
end
|
||||
|
||||
description = description_parts.presence&.join("\n\n")
|
||||
{ description:, placements:, members: build_members(placements),
|
||||
compatibility_body: @body.sub(/\n+\z/, '') }
|
||||
rescue Kramdown::Error => error
|
||||
invalid!("Markdown を解析できません:#{ error.message }")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_list_item locale, item
|
||||
invalid!('リスト項目ではありません.') unless item.type == :li
|
||||
invalid!('未対応のリスト項目があります.') if item.attr.any?
|
||||
|
||||
nested, own = item.children.partition { |child| child.type == :ul }
|
||||
invalid!('順序付きリストは使用できません.') if own.any? { _1.type == :ol }
|
||||
own.reject! { _1.type == :blank }
|
||||
invalid!('未対応のリスト項目があります.') unless own.all? do |child|
|
||||
[:p, :text].include?(child.type)
|
||||
end
|
||||
invalid!('リスト項目は一つの段落で指定してください.') if own.size != 1
|
||||
|
||||
label = own.map { inline_text(_1) }.join(' ').strip
|
||||
invalid!('空のリスト項目は使用できません.') if label.blank?
|
||||
|
||||
node =
|
||||
if (group_name = TagGroupRepr.name_from_title(label))
|
||||
group_name = group_name.strip
|
||||
child_group = TagGroup.find_by(name: group_name)
|
||||
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
|
||||
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
|
||||
{ 'tag_group_id' => child_group.id }
|
||||
elsif label.end_with?(':', ':')
|
||||
section_name = label.sub(/[::]\z/, '').strip
|
||||
invalid!('Section 名は必須です.') if section_name.blank?
|
||||
{ 'section_name' => section_name, 'description' => nil }
|
||||
else
|
||||
{ 'tag_id' => resolve_tag(locale, label) }
|
||||
end
|
||||
|
||||
node['children'] = nested.flat_map do |list|
|
||||
invalid!('未対応の入れ子リストです.') unless list.type == :ul
|
||||
list.children.map { parse_list_item(locale, _1) }
|
||||
end
|
||||
node
|
||||
end
|
||||
|
||||
def resolve_tag locale, name
|
||||
names = TagName.where(language_code: locale.language_code, name:).to_a
|
||||
tag_ids = names.filter_map(&:tag_id).uniq
|
||||
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
|
||||
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
|
||||
|
||||
tag_ids.first
|
||||
end
|
||||
|
||||
def build_members(nodes)
|
||||
references = []
|
||||
visit = lambda do |items|
|
||||
items.each do |node|
|
||||
references << node.slice('tag_id', 'tag_group_id') if
|
||||
node.key?('tag_id') || node.key?('tag_group_id')
|
||||
visit.call(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
visit.call(nodes)
|
||||
references.uniq
|
||||
end
|
||||
|
||||
def inline_text(node)
|
||||
if node.attr.any?
|
||||
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
|
||||
case node.type
|
||||
when :text
|
||||
node.value.to_s
|
||||
when :p, :header
|
||||
node.children.map { inline_text(_1) }.join
|
||||
else
|
||||
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
end
|
||||
|
||||
def inline_markdown(node)
|
||||
case node.type
|
||||
when :text
|
||||
node.value.to_s
|
||||
when :codespan
|
||||
text = node.value.to_s
|
||||
longest = text.scan(/`+/).map(&:length).max || 0
|
||||
delimiter = '`' * (longest + 1)
|
||||
padded = text.start_with?('`', ' ') || text.end_with?('`', ' ')
|
||||
content = padded ? " #{ text } " : text
|
||||
"#{ delimiter }#{ content }#{ delimiter }"
|
||||
when :softbreak
|
||||
"\n"
|
||||
when :br
|
||||
" \n"
|
||||
when :p
|
||||
node.children.map { inline_markdown(_1) }.join
|
||||
when :a
|
||||
href = node.attr.fetch('href')
|
||||
label = node.children.map { inline_markdown(_1) }.join
|
||||
title = node.attr['title']&.gsub('"', '\\"')
|
||||
suffix = title ? " \"#{ title }\"" : ''
|
||||
"[#{ label }](#{ href }#{ suffix })"
|
||||
when :em
|
||||
"*#{ node.children.map { inline_markdown(_1) }.join }*"
|
||||
when :strong
|
||||
"**#{ node.children.map { inline_markdown(_1) }.join }**"
|
||||
else
|
||||
invalid!("未対応の説明文構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
end
|
||||
|
||||
def invalid! message
|
||||
raise InvalidDocument, message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
class TagGroupMarkdownRenderer
|
||||
class UnsupportedStructure < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.render group
|
||||
new(group).render
|
||||
end
|
||||
|
||||
def initialize group
|
||||
@group = group
|
||||
end
|
||||
|
||||
def render
|
||||
placements = @group.fetch('placements')
|
||||
validate_membership!(placements)
|
||||
blocks = []
|
||||
if @group['description'].present?
|
||||
blocks << clean_text(@group['description'])
|
||||
end
|
||||
|
||||
first_section = placements.index { _1.key?('section_name') }
|
||||
|
||||
if first_section
|
||||
if placements.drop(first_section).any? { !_1.key?('section_name') }
|
||||
raise UnsupportedStructure,
|
||||
'見出し Section の後に最上位 Placement は配置できません.'
|
||||
end
|
||||
|
||||
if first_section.positive?
|
||||
blocks << render_list(placements.take(first_section), 0)
|
||||
end
|
||||
|
||||
blocks.concat(placements.drop(first_section).map { render_heading(_1, 2) })
|
||||
elsif placements.any?
|
||||
blocks << render_list(placements, 0)
|
||||
end
|
||||
|
||||
blocks.compact.reject(&:empty?).join("\n").sub(/\n+\z/, '')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_membership! placements
|
||||
placed = placement_members(placements).uniq.sort
|
||||
members = @group.fetch('members').map { |member|
|
||||
if member['tag']
|
||||
['tag', member.fetch('tag').fetch('id')]
|
||||
else
|
||||
['tag_group', member.fetch('tag_group').fetch('id')]
|
||||
end
|
||||
}.uniq.sort
|
||||
missing = members - placed
|
||||
if missing.any?
|
||||
raise UnsupportedStructure, '掲載位置のない membership は Markdown にできません.'
|
||||
end
|
||||
end
|
||||
|
||||
def placement_members nodes
|
||||
nodes.flat_map do |node|
|
||||
own = if node['tag']
|
||||
[['tag', node.fetch('tag').fetch('id')]]
|
||||
elsif node['tag_group']
|
||||
[['tag_group', node.fetch('tag_group').fetch('id')]]
|
||||
else
|
||||
[]
|
||||
end
|
||||
own + placement_members(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
|
||||
def render_heading section, level
|
||||
header = "#{ '#' * level } #{ clean_text(section.fetch('section_name')) }"
|
||||
blocks = [header]
|
||||
blocks << clean_text(section['description']) if section['description'].present?
|
||||
children = section.fetch('children')
|
||||
section_indexes = children.each_index.select { |index| children[index].key?('section_name') }
|
||||
if section_indexes.any?
|
||||
first_section = section_indexes.first
|
||||
if children[first_section..].any? { !_1.key?('section_name') }
|
||||
raise UnsupportedStructure,
|
||||
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
|
||||
end
|
||||
list_nodes = children[0...first_section]
|
||||
blocks << render_list(list_nodes, 0) if list_nodes.any?
|
||||
blocks.concat(children[first_section..].map { render_heading(_1, level + 1) })
|
||||
elsif children.any?
|
||||
blocks << render_list(children, 0)
|
||||
end
|
||||
blocks.join("\n")
|
||||
end
|
||||
|
||||
def render_list nodes, depth
|
||||
nodes.map do |node|
|
||||
indent = ' ' * depth
|
||||
if node.key?('section_name')
|
||||
raise UnsupportedStructure,
|
||||
'リスト内 Section の説明文は Markdown にできません.' if node['description'].present?
|
||||
|
||||
children = node.fetch('children')
|
||||
head = "#{ indent }- #{ clean_text(node.fetch('section_name')) }:"
|
||||
tail = render_list(children, depth + 1) if children.any?
|
||||
[head, tail].compact.join("\n")
|
||||
else
|
||||
label = if node['tag']
|
||||
node.fetch('tag').fetch('name')
|
||||
else
|
||||
"#{ TagGroupRepr::PREFIX }#{ node.fetch('tag_group').fetch('name') }"
|
||||
end
|
||||
raise UnsupportedStructure, '名称のない Placement は Markdown にできません.' if label.blank?
|
||||
|
||||
head = "#{ indent }- #{ clean_text(label) }"
|
||||
children = node.fetch('children')
|
||||
tail = render_list(children, depth + 1) if children.any?
|
||||
[head, tail].compact.join("\n")
|
||||
end
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def clean_text value
|
||||
value.to_s.gsub(/\r\n?/, "\n").lines.map(&:rstrip).join("\n").strip
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,362 @@
|
||||
class TagGroupOperation
|
||||
MAX_PLACEMENT_DEPTH = 5
|
||||
|
||||
def self.create!(locale, name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false)
|
||||
ApplicationRecord.transaction do
|
||||
group = TagGroup.new(version_no: 1, created_by_user: actor,
|
||||
updated_by_user: actor)
|
||||
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
|
||||
group.assign_attributes(name: attributes.fetch(:name),
|
||||
description: attributes.fetch(:description))
|
||||
group.save!
|
||||
persist_structure!(group:, attributes:, actor:)
|
||||
validate_compatibility!(locale, group) if require_compatibility
|
||||
persist_version!(group:, attributes:, actor:, event_type: 'create')
|
||||
group
|
||||
end
|
||||
end
|
||||
|
||||
def self.update!(locale, group:, name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false)
|
||||
ApplicationRecord.transaction do
|
||||
group.lock!
|
||||
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
|
||||
if unchanged?(group, attributes)
|
||||
validate_compatibility!(locale, group) if require_compatibility
|
||||
next group
|
||||
end
|
||||
|
||||
group.update!(name: attributes.fetch(:name),
|
||||
description: attributes.fetch(:description),
|
||||
version_no: group.version_no + 1,
|
||||
updated_by_user: actor)
|
||||
persist_structure!(group:, attributes:, actor:)
|
||||
validate_compatibility!(locale, group) if require_compatibility
|
||||
persist_version!(group:, attributes:, actor:, event_type: 'update')
|
||||
group
|
||||
end
|
||||
end
|
||||
|
||||
def self.validate_compatibility! locale, group
|
||||
TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group))
|
||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||
invalid!(group, :base, error.message)
|
||||
end
|
||||
private_class_method :validate_compatibility!
|
||||
|
||||
def self.prepare!(locale:, group:, name:, description:, members:, placements:)
|
||||
record = group
|
||||
normalized_name = name.to_s.strip
|
||||
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
|
||||
if normalized_name.length > 255
|
||||
invalid!(record, :name, 'グループ名は 255 文字以内で入力してください.')
|
||||
end
|
||||
invalid!(record, :description, '説明文は文字列で入力してください.') unless
|
||||
description.nil? || description.is_a?(String)
|
||||
|
||||
record.name = normalized_name
|
||||
record.description = description
|
||||
unless record.valid?
|
||||
raise ActiveRecord::RecordInvalid, record
|
||||
end
|
||||
|
||||
member_rows = normalize_members(record, members)
|
||||
placement_tree = normalize_placements(record, placements)
|
||||
validate_references!(locale, record, member_rows, placement_tree)
|
||||
validate_containment!(record, member_rows)
|
||||
{ name: normalized_name, description:, members: member_rows,
|
||||
placements: placement_tree }
|
||||
end
|
||||
private_class_method :prepare!
|
||||
|
||||
def self.unchanged?(group, attributes)
|
||||
unless (group.name_in_database == attributes.fetch(:name) &&
|
||||
group.description_in_database == attributes.fetch(:description))
|
||||
return false
|
||||
end
|
||||
|
||||
members = TagGroupTag.where(tag_group_id: group.id).pluck(:tag_id)
|
||||
.map { { 'tag_id' => _1 } }
|
||||
members.concat(TagGroupGroup.where(tag_group_id: group.id)
|
||||
.pluck(:child_tag_group_id).map { { 'tag_group_id' => _1 } })
|
||||
requested_members = attributes.fetch(:members).map do |member|
|
||||
member.except('version_no')
|
||||
end
|
||||
return false unless members.to_set == requested_members.to_set
|
||||
|
||||
rows = TagGroupPlacement.where(tag_group_id: group.id).order(:position)
|
||||
.group_by(&:parent_position)
|
||||
build = lambda do |parent_position|
|
||||
rows.fetch(parent_position, []).map do |row|
|
||||
node = if row.tag_id
|
||||
{ 'tag_id' => row.tag_id }
|
||||
elsif row.child_tag_group_id
|
||||
{ 'tag_group_id' => row.child_tag_group_id }
|
||||
else
|
||||
{ 'section_name' => row.section_name, 'description' => row.description }
|
||||
end
|
||||
node.merge('children' => build.call(row.position))
|
||||
end
|
||||
end
|
||||
build.call(nil) == comparable_placements(attributes.fetch(:placements))
|
||||
end
|
||||
private_class_method :unchanged?
|
||||
|
||||
def self.comparable_placements(nodes)
|
||||
nodes.map do |node|
|
||||
result = node.dup
|
||||
result['description'] = node['description'] if node.key?('section_name')
|
||||
result['children'] = comparable_placements(node.fetch('children'))
|
||||
result
|
||||
end
|
||||
end
|
||||
private_class_method :comparable_placements
|
||||
|
||||
def self.normalize_members(record, members)
|
||||
invalid!(record, :members, 'members は配列で指定してください.') unless members.is_a?(Array)
|
||||
|
||||
result = members.map do |member|
|
||||
member = stringify_keys(member)
|
||||
unless member.is_a?(Hash) && member.keys.one? &&
|
||||
(member.key?('tag_id') || member.key?('tag_group_id'))
|
||||
invalid!(record, :members, '各 member は tag_id または tag_group_id を一つ指定してください.')
|
||||
end
|
||||
key = member.keys.first
|
||||
id = integer_id(record, member.fetch(key), :members)
|
||||
{ key => id }
|
||||
end
|
||||
invalid!(record, :members, 'membership に重複があります.') unless result.uniq == result
|
||||
result
|
||||
end
|
||||
private_class_method :normalize_members
|
||||
|
||||
def self.normalize_placements(record, placements)
|
||||
invalid!(record, :placements, 'placements は配列で指定してください.') unless
|
||||
placements.is_a?(Array)
|
||||
|
||||
normalize_placement_nodes(record, placements, 1)
|
||||
end
|
||||
private_class_method :normalize_placements
|
||||
|
||||
def self.normalize_placement_nodes(record, nodes, depth)
|
||||
if depth > MAX_PLACEMENT_DEPTH && nodes.any?
|
||||
invalid!(record, :placements, '配置の深さは 5 階層までです.')
|
||||
end
|
||||
|
||||
nodes.map do |raw_node|
|
||||
node = stringify_keys(raw_node)
|
||||
unless node.is_a?(Hash) && node.key?('children')
|
||||
invalid!(record, :placements, '各 Placement に children 配列が必要です.')
|
||||
end
|
||||
children = node.delete('children')
|
||||
unless children.is_a?(Array)
|
||||
invalid!(record, :placements, 'children は配列で指定してください.')
|
||||
end
|
||||
|
||||
kinds = ['tag_id', 'tag_group_id', 'section_name'].select { node.key?(_1) }
|
||||
invalid!(record, :placements, 'Placement の種類を一つ指定してください.') unless
|
||||
kinds.one?
|
||||
kind = kinds.first
|
||||
allowed = [kind, 'description']
|
||||
invalid!(record, :placements, '不明な Placement 項目があります.') unless
|
||||
(node.keys - allowed).empty?
|
||||
|
||||
case kind
|
||||
when 'tag_id', 'tag_group_id'
|
||||
node[kind] = integer_id(record, node.fetch(kind), :placements)
|
||||
if node['description'].present?
|
||||
invalid!(record, :placements, 'Tag / Tag Group Placement に説明文は指定できません.')
|
||||
end
|
||||
node.delete('description')
|
||||
when 'section_name'
|
||||
section_name = node.fetch(kind)
|
||||
unless section_name.is_a?(String) && section_name.strip.present? &&
|
||||
section_name.length <= 255
|
||||
invalid!(record, :placements, 'Section 名は 1〜255 文字で入力してください.')
|
||||
end
|
||||
node[kind] = section_name.strip
|
||||
unless node['description'].nil? || node['description'].is_a?(String)
|
||||
invalid!(record, :placements, 'Section の説明文は文字列で入力してください.')
|
||||
end
|
||||
end
|
||||
|
||||
node['children'] = normalize_placement_nodes(record, children, depth + 1)
|
||||
node
|
||||
end
|
||||
end
|
||||
private_class_method :normalize_placement_nodes
|
||||
|
||||
def self.validate_references!(locale, record, members, placements)
|
||||
membership = members.to_set
|
||||
placed = placement_references(placements).uniq
|
||||
missing = placed - membership.to_a
|
||||
invalid!(record, :placements, 'Placement の対象は membership に含めてください.') if
|
||||
missing.any?
|
||||
|
||||
tag_ids = members.filter_map { _1['tag_id'] }
|
||||
group_ids = members.filter_map { _1['tag_group_id'] }
|
||||
tags = Tag.where(id: tag_ids).index_by(&:id)
|
||||
groups = TagGroup.where(id: group_ids).index_by(&:id)
|
||||
invalid!(record, :members, '存在しない Tag が含まれています.') if
|
||||
tags.size != tag_ids.uniq.size
|
||||
invalid!(record, :members, '存在しない Tag Group が含まれています.') if
|
||||
groups.size != group_ids.uniq.size
|
||||
|
||||
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
|
||||
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
|
||||
.pluck(:tag_group_id, :version_no).to_set
|
||||
tag_names = TagName.where(
|
||||
tag_id: tag_ids, language_code: locale.language_code, primary_flg: true)
|
||||
.pluck(:tag_id).uniq
|
||||
|
||||
members.each do |member|
|
||||
if member.key?('tag_id')
|
||||
id = member.fetch('tag_id')
|
||||
invalid!(record, :members, :invalid) unless
|
||||
tag_names.include?(id)
|
||||
version_no = tags.fetch(id).version_no
|
||||
unless tag_versions.include?([id, version_no])
|
||||
invalid!(record, :members, "Tag ##{ id } の Version がありません.")
|
||||
end
|
||||
member['version_no'] = version_no
|
||||
else
|
||||
id = member.fetch('tag_group_id')
|
||||
version_no = groups.fetch(id).version_no
|
||||
unless group_versions.include?([id, version_no])
|
||||
invalid!(record, :members, "Tag Group ##{ id } の Version がありません.")
|
||||
end
|
||||
member['version_no'] = version_no
|
||||
end
|
||||
end
|
||||
end
|
||||
private_class_method :validate_references!
|
||||
|
||||
def self.validate_containment!(record, members)
|
||||
target_id = record.id
|
||||
return unless target_id
|
||||
|
||||
children = members.filter_map { _1['tag_group_id'] }
|
||||
edges = TagGroupGroup.pluck(:tag_group_id, :child_tag_group_id)
|
||||
.group_by(&:first).transform_values { _1.map(&:last) }
|
||||
edges[target_id] = children
|
||||
visiting = Set.new
|
||||
visited = Set.new
|
||||
visit = lambda do |group_id|
|
||||
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
|
||||
group_id == target_id && visiting.include?(group_id)
|
||||
return if visited.include?(group_id)
|
||||
|
||||
visiting.add(group_id)
|
||||
edges.fetch(group_id, []).each do |child_id|
|
||||
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
|
||||
child_id == target_id
|
||||
visit.call(child_id)
|
||||
end
|
||||
visiting.delete(group_id)
|
||||
visited.add(group_id)
|
||||
end
|
||||
children.each { visit.call(_1) }
|
||||
end
|
||||
private_class_method :validate_containment!
|
||||
|
||||
def self.persist_structure!(group:, attributes:, actor:)
|
||||
tag_members = TagGroupTag.where(tag_group_id: group.id).index_by(&:tag_id)
|
||||
group_members = TagGroupGroup.where(tag_group_id: group.id).index_by(&:child_tag_group_id)
|
||||
attributes.fetch(:members).each do |member|
|
||||
if member.key?('tag_id')
|
||||
next if tag_members.delete(member.fetch('tag_id'))
|
||||
|
||||
TagGroupTag.create!(tag_group_id: group.id, tag_id: member.fetch('tag_id'),
|
||||
created_by_user: actor)
|
||||
else
|
||||
next if group_members.delete(member.fetch('tag_group_id'))
|
||||
|
||||
TagGroupGroup.create!(tag_group_id: group.id,
|
||||
child_tag_group_id: member.fetch('tag_group_id'),
|
||||
created_by_user: actor)
|
||||
end
|
||||
end
|
||||
existing = TagGroupPlacement.where(tag_group_id: group.id).index_by(&:position)
|
||||
persist_placements!(group:, nodes: attributes.fetch(:placements), actor:, existing:)
|
||||
existing.values.sort_by(&:position).reverse_each(&:delete)
|
||||
tag_members.each_value(&:delete)
|
||||
group_members.each_value(&:delete)
|
||||
end
|
||||
private_class_method :persist_structure!
|
||||
|
||||
def self.persist_placements!(group:, nodes:, actor:, existing:, parent_position: nil,
|
||||
next_position: [0])
|
||||
nodes.each do |node|
|
||||
next_position[0] += 1
|
||||
position = next_position[0]
|
||||
row = existing.delete(position) || TagGroupPlacement.new(
|
||||
tag_group_id: group.id, position:, created_by_user: actor)
|
||||
row.assign_attributes(
|
||||
parent_position:,
|
||||
section_name: node['section_name'], description: node['description'],
|
||||
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'])
|
||||
if row.new_record? || row.changed?
|
||||
row.updated_by_user = actor
|
||||
row.save!
|
||||
end
|
||||
persist_placements!(group:, nodes: node.fetch('children'), actor:, existing:,
|
||||
parent_position: position, next_position:)
|
||||
end
|
||||
end
|
||||
private_class_method :persist_placements!
|
||||
|
||||
def self.persist_version!(group:, attributes:, actor:, event_type:)
|
||||
TagGroupVersion.create!(tag_group: group, version_no: group.version_no,
|
||||
event_type:, name: group.name,
|
||||
description: group.description,
|
||||
members: attributes.fetch(:members),
|
||||
placements: attributes.fetch(:placements),
|
||||
created_by_user: actor)
|
||||
end
|
||||
private_class_method :persist_version!
|
||||
|
||||
def self.placement_references(nodes)
|
||||
nodes.flat_map do |node|
|
||||
own = if node.key?('tag_id')
|
||||
[{ 'tag_id' => node.fetch('tag_id') }]
|
||||
elsif node.key?('tag_group_id')
|
||||
[{ 'tag_group_id' => node.fetch('tag_group_id') }]
|
||||
else
|
||||
[]
|
||||
end
|
||||
own + placement_references(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
private_class_method :placement_references
|
||||
|
||||
def self.stringify_keys(value)
|
||||
case value
|
||||
when ActionController::Parameters
|
||||
value.to_unsafe_h.deep_stringify_keys
|
||||
when Hash
|
||||
value.deep_stringify_keys
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
private_class_method :stringify_keys
|
||||
|
||||
def self.integer_id(record, value, attribute)
|
||||
integer = if value.is_a?(Integer)
|
||||
value
|
||||
elsif value.is_a?(String) && value.match?(/\A[0-9]+\z/)
|
||||
value.to_i
|
||||
end
|
||||
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer
|
||||
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer.positive?
|
||||
integer
|
||||
end
|
||||
private_class_method :integer_id
|
||||
|
||||
def self.invalid!(record, attribute, message)
|
||||
record.errors.add(attribute, message)
|
||||
raise ActiveRecord::RecordInvalid, record
|
||||
end
|
||||
private_class_method :invalid!
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
class TagNameCompletion
|
||||
class << self
|
||||
def for_locale! locale
|
||||
completed = Tag.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
certified_names =
|
||||
if locale.certified_tag_names_path.nil?
|
||||
{ }
|
||||
else
|
||||
CertifiedTagNameTranslations.load!(locale.certified_tag_names_path, locale:)
|
||||
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) })
|
||||
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])
|
||||
end
|
||||
true
|
||||
end
|
||||
unless completed
|
||||
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
|
||||
end
|
||||
end
|
||||
|
||||
def for_tag! tag, locales: nil, certified_names: { }
|
||||
locales ||=
|
||||
Locale.where(deprecated_at: nil)
|
||||
.order(:language_code, :created_at, :script_code).lock
|
||||
Tag.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
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|
|
||||
primary = primary_names[language]
|
||||
next unless primary
|
||||
next if primary.name == name && !primary.auto_generated
|
||||
|
||||
raise CertifiedTagNameTranslations::Error,
|
||||
"Tag##{ tag.id } already has a primary #{ language } name; " +
|
||||
"cannot register certified name #{ name.inspect }"
|
||||
end
|
||||
missing_locales = locales.to_a.uniq(&:language_code).reject { |locale|
|
||||
primary_names.key?(locale.language_code)
|
||||
}
|
||||
next true if missing_locales.empty?
|
||||
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: nil)
|
||||
missing_locales.each do |locale|
|
||||
primary_names[locale.language_code] =
|
||||
if certified_names.key?(locale.language_code)
|
||||
create_certified_name!(locale, tag, certified_names.fetch(locale.language_code))
|
||||
else
|
||||
source_language = locale.language_code == 'ja' ? 'ain' : 'ja'
|
||||
name = TagName.generate_name(locale, tag, primary_names[source_language])
|
||||
create_name!(locale, tag, name)
|
||||
end
|
||||
end
|
||||
tag.association(:tag_names).reset
|
||||
version = TagVersioning.record!(tag, event_type: :update, created_by_user: nil)
|
||||
unless version&.persisted?
|
||||
raise ActiveRecord::RecordNotSaved, 'Tag version was not persisted'
|
||||
end
|
||||
true
|
||||
end
|
||||
unless completed
|
||||
raise ActiveRecord::RecordNotSaved, 'Tag completion was rolled back'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_certified_name! locale, tag, name
|
||||
persist_name!(locale, tag, name, auto_generated: false)
|
||||
rescue ActiveRecord::RecordInvalid => error
|
||||
raise unless error.record.is_a?(TagName)
|
||||
|
||||
raise CertifiedTagNameTranslations::Error,
|
||||
"Tag##{ tag.id } certified name #{ name.inspect }: " +
|
||||
error.record.errors.full_messages.join(', ')
|
||||
rescue ActiveRecord::RecordNotUnique => error
|
||||
raise CertifiedTagNameTranslations::Error,
|
||||
"Tag##{ tag.id } certified name #{ name.inspect } conflicts: #{ error.message }"
|
||||
end
|
||||
|
||||
def create_name! locale, tag, name
|
||||
persist_name!(locale, tag, name, auto_generated: true)
|
||||
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
|
||||
fallback = "Tag_##{ tag.id }"
|
||||
invalid_name =
|
||||
if error.is_a?(ActiveRecord::RecordNotUnique)
|
||||
TagName.where(language_code: locale.language_code, name:).lock.exists?
|
||||
else
|
||||
error.record.errors[:name].present? &&
|
||||
error.record.errors.attribute_names.all? { _1 == :name }
|
||||
end
|
||||
raise if name == fallback || !(invalid_name)
|
||||
|
||||
persist_name!(locale, tag, fallback, auto_generated: true)
|
||||
end
|
||||
|
||||
def persist_name! locale, tag, name, auto_generated:
|
||||
tag_name = TagName.transaction(requires_new: true) do
|
||||
TagName.create!(tag:, name:, language_code: locale.language_code,
|
||||
script_code: locale.script_code, primary_flg: true, auto_generated:)
|
||||
end
|
||||
unless tag_name&.persisted?
|
||||
raise ActiveRecord::RecordNotSaved, 'Generated tag name was not persisted'
|
||||
end
|
||||
tag_name
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
module TagNameTranscription
|
||||
class Unconvertible < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.ainu name
|
||||
previous_script = nil
|
||||
parts = []
|
||||
NipponeseSegments.split(name).each do |script, text|
|
||||
output = transcribe_segment(script, text)
|
||||
if (parts.last && output.first && previous_script && script != previous_script &&
|
||||
NipponeseSegments.nipponese?(previous_script) && NipponeseSegments.nipponese?(script))
|
||||
parts << '_'
|
||||
end
|
||||
|
||||
parts.concat(output)
|
||||
previous_script = script
|
||||
end
|
||||
|
||||
replace_unconvertible(parts)
|
||||
end
|
||||
|
||||
def self.transcribe_segment script, text
|
||||
return [text] if script == :literal
|
||||
return [nil] if script == :unconvertible
|
||||
return transcribe_reading(text) unless script == :kanzi
|
||||
|
||||
text.each_char.flat_map do |character|
|
||||
transcribe_reading(JisReadings.read(character))
|
||||
rescue Unconvertible
|
||||
[nil]
|
||||
end
|
||||
end
|
||||
|
||||
def self.transcribe_reading reading
|
||||
Phonology.adapt(reading).map do |sound|
|
||||
sound && Katakana.render([sound])
|
||||
rescue Unconvertible
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def self.replace_unconvertible parts
|
||||
grouped = parts.chunk { _1.nil? }.flat_map { |unconvertible, group|
|
||||
unconvertible ? [nil] : group
|
||||
}
|
||||
first = grouped.index { _1&.match?(/[^_]/) }
|
||||
last = grouped.rindex { _1&.match?(/[^_]/) }
|
||||
|
||||
grouped.each_with_index.map { |part, index|
|
||||
part || (first && first < index && index < last ? '_' : '')
|
||||
}.join
|
||||
end
|
||||
|
||||
private_class_method :transcribe_segment, :transcribe_reading, :replace_unconvertible
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
module TagNameTranscription
|
||||
module JisReadings
|
||||
SOURCE = Rails.root.join('config/tag_name_readings/jis_level_one.tsv')
|
||||
READINGS = File.foreach(SOURCE, chomp: true).reject { _1.start_with?('#') }.to_h { |line|
|
||||
character, reading, = line.split("\t")
|
||||
[character, reading]
|
||||
}.freeze
|
||||
|
||||
def self.read text
|
||||
text.each_char.map { READINGS.fetch(_1) { raise Unconvertible } }.join
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
module TagNameTranscription
|
||||
module Katakana
|
||||
ROWS = {
|
||||
'' => 'アイウエオ',
|
||||
'k' => 'カキクケコ',
|
||||
's' => 'サシスセソ',
|
||||
'c' => ['チャ', 'チ', 'チュ', 'チェ', 'チョ'],
|
||||
't' => ['タ', 'ティ', 'トゥ', 'テ', 'ト'],
|
||||
'n' => 'ナニヌネノ',
|
||||
'h' => 'ハヒフヘホ',
|
||||
'p' => 'パピプペポ',
|
||||
'm' => 'マミムメモ',
|
||||
'r' => 'ラリルレロ' }.freeze
|
||||
SYLLABLES = ROWS.flat_map { |onset, letters|
|
||||
kana = letters.is_a?(String) ? letters.chars : letters
|
||||
['a', 'i', 'u', 'e', 'o'].zip(kana).map { |vowel, letter| [onset + vowel, letter] }
|
||||
}.to_h.merge(
|
||||
'ya' => 'ヤ', 'yu' => 'ユ', 'yo' => 'ヨ', 'wa' => 'ワ',
|
||||
'k' => 'ㇰ', 's' => 'ㇱ', 't' => 'ッ', 'n' => 'ン', 'm' => 'ㇺ', 'p' => 'ㇷ゚',
|
||||
'r:a' => 'ㇻ', 'r:i' => 'ㇼ', 'r:u' => 'ㇽ', 'r:e' => 'ㇾ', 'r:o' => 'ㇿ').freeze
|
||||
|
||||
def self.render sounds
|
||||
sounds.map { SYLLABLES.fetch(_1) { raise Unconvertible } }.join
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
module TagNameTranscription
|
||||
module NipponeseSegments
|
||||
NIPPONESE_SCRIPTS = [:kanzi, :xiragana, :katakana].freeze
|
||||
|
||||
def self.nipponese?(script) = NIPPONESE_SCRIPTS.include?(script)
|
||||
|
||||
def self.split name
|
||||
segments = []
|
||||
name.to_s.scan(/\X/).each do |character|
|
||||
script = classify(character, segments.last&.first)
|
||||
if segments.last&.first == script
|
||||
segments.last[1] << character
|
||||
else
|
||||
segments << [script, character.dup]
|
||||
end
|
||||
end
|
||||
segments
|
||||
end
|
||||
|
||||
def self.classify character, previous_script
|
||||
return previous_script if character == 'ー' && nipponese?(previous_script)
|
||||
return :kanzi if character.match?(/\A\p{Han}\z/)
|
||||
return :xiragana if character.match?(/\A\p{Hiragana}\p{M}*\z/)
|
||||
return :katakana if character.match?(/\A\p{Katakana}\p{M}*\z/)
|
||||
return :literal if character.match?(/\A(?:\p{Latin}\p{M}*|[!-~])\z/)
|
||||
|
||||
:unconvertible
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
module TagNameTranscription
|
||||
module Phonology
|
||||
ROWS = {
|
||||
'アイウエオ' => ['a', 'i', 'u', 'e', 'o'],
|
||||
'カキクケコ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
|
||||
'ガギグゲゴ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
|
||||
'サシスセソ' => ['sa', 'si', 'su', 'se', 'so'],
|
||||
'ザジズゼゾ' => ['ca', 'ci', 'cu', 'ce', 'co'],
|
||||
'タチツテト' => ['ta', 'ci', 'cu', 'te', 'to'],
|
||||
'ダヂヅデド' => ['ta', 'ci', 'cu', 'te', 'to'],
|
||||
'ナニヌネノ' => ['na', 'ni', 'nu', 'ne', 'no'],
|
||||
'ハヒフヘホ' => ['ha', 'hi', 'hu', 'he', 'ho'],
|
||||
'バビブベボ' => ['pa', 'pi', 'pu', 'pe', 'po'],
|
||||
'パピプペポ' => ['pa', 'pi', 'pu', 'pe', 'po'],
|
||||
'マミムメモ' => ['ma', 'mi', 'mu', 'me', 'mo'],
|
||||
'ヤユヨ' => ['ya', 'yu', 'yo'],
|
||||
'ラリルレロ' => ['ra', 'ri', 'ru', 're', 'ro'],
|
||||
'ワ' => ['wa'],
|
||||
'ン' => ['n'],
|
||||
'ッ' => ['t'],
|
||||
'ㇰ' => ['k'],
|
||||
'ㇱㇲ' => ['s', 's'],
|
||||
'ㇳ' => ['t'],
|
||||
'ㇴ' => ['n'],
|
||||
'ㇺ' => ['m'],
|
||||
'ㇻㇼㇽㇾㇿ' => ['r:a', 'r:i', 'r:u', 'r:e', 'r:o'] }.freeze
|
||||
|
||||
SOUNDS = ROWS.flat_map { |characters, sounds| characters.chars.zip(sounds) }.to_h.freeze
|
||||
|
||||
DIGRAPHS = {
|
||||
'シャ' => 'sa', 'シュ' => 'su', 'シェ' => 'se', 'ショ' => 'so',
|
||||
'チャ' => 'ca', 'チュ' => 'cu', 'チェ' => 'ce', 'チョ' => 'co',
|
||||
'ジャ' => 'ca', 'ジュ' => 'cu', 'ジェ' => 'ce', 'ジョ' => 'co',
|
||||
'ヂャ' => 'ca', 'ヂュ' => 'cu', 'ヂェ' => 'ce', 'ヂョ' => 'co' }.freeze
|
||||
|
||||
def self.adapt reading
|
||||
kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').delete('ー')
|
||||
characters = kana.scan(/\X/)
|
||||
sounds = []
|
||||
index = 0
|
||||
|
||||
while index < characters.length
|
||||
pair = characters[index, 2].join
|
||||
|
||||
if DIGRAPHS.key?(pair)
|
||||
sounds << DIGRAPHS.fetch(pair)
|
||||
index += 2
|
||||
next
|
||||
end
|
||||
|
||||
character = characters[index].tr('ャュョ', 'ヤユヨ')
|
||||
sounds << (character == 'ㇷ゚' ? 'p' : SOUNDS[character])
|
||||
index += 1
|
||||
end
|
||||
|
||||
sounds
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,9 @@
|
||||
class TagVersionRecorder < VersionRecorder
|
||||
def self.record! tag:, event_type:, created_by_user:, **_options
|
||||
new(tag:, event_type:, created_by_user:).record!
|
||||
Tag.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
new(tag:, event_type:, created_by_user:).record!
|
||||
end
|
||||
end
|
||||
|
||||
def initialize tag:, event_type:, created_by_user:
|
||||
@@ -15,18 +18,21 @@ class TagVersionRecorder < VersionRecorder
|
||||
|
||||
def snapshot_attributes
|
||||
names = snapshot_names
|
||||
compatibility_names = names.fetch('ja')
|
||||
compatibility_names = names['ja'] || names.fetch('ain')
|
||||
parent_ids =
|
||||
TagImplication.where(tag_id: @record.id).order(:parent_tag_id)
|
||||
.lock.pluck(:parent_tag_id)
|
||||
|
||||
{ name: compatibility_names.fetch('name'),
|
||||
category: @record.category,
|
||||
deprecated_at: @record.deprecated_at,
|
||||
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
|
||||
names:,
|
||||
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
||||
parent_tag_ids: parent_ids.join(' ') }
|
||||
end
|
||||
|
||||
def snapshot_names
|
||||
@record.tag_names.order(:language_code, :name).group_by(&:language_code)
|
||||
@record.tag_names.order(:language_code, :name).lock.group_by(&:language_code)
|
||||
.transform_values do |tag_names|
|
||||
primary = tag_names.find(&:primary?)
|
||||
aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name|
|
||||
|
||||
@@ -4,14 +4,23 @@ class TagVersioning
|
||||
end
|
||||
|
||||
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
|
||||
return if tag.tag_versions.exists?
|
||||
Tag.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
locked_tag = Tag.unscoped.lock.find(tag.id)
|
||||
next if locked_tag.tag_versions.lock.exists?
|
||||
|
||||
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:, language_code:)
|
||||
TagVersionRecorder.record!(
|
||||
tag: locked_tag, event_type: :create, created_by_user:, language_code:)
|
||||
end
|
||||
end
|
||||
|
||||
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
|
||||
event_type = tag.tag_versions.exists? ? :update : :create
|
||||
record!(tag, event_type:, created_by_user:, language_code:)
|
||||
Tag.transaction do
|
||||
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||
locked_tag = Tag.unscoped.lock.find(tag.id)
|
||||
event_type = locked_tag.tag_versions.lock.exists? ? :update : :create
|
||||
record!(locked_tag, event_type:, created_by_user:, language_code:)
|
||||
end
|
||||
end
|
||||
|
||||
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
|
||||
|
||||
@@ -35,7 +35,7 @@ class VersionRecorder
|
||||
|
||||
private
|
||||
|
||||
def latest_version = versions.order(version_no: :desc).first
|
||||
def latest_version = versions.order(version_no: :desc).lock.first
|
||||
|
||||
def versions = @record.public_send(version_association)
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
require 'digest'
|
||||
|
||||
|
||||
module Wiki
|
||||
class Commit
|
||||
class Conflict < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.create_content! tag_name:, body:, created_by_user:, message: nil
|
||||
if tag_name.tag && tag_name.language_code == 'ja'
|
||||
return TagDescriptionOperation.create!(
|
||||
tag: tag_name.tag, body:, created_by_user:, message:)
|
||||
end
|
||||
|
||||
create_legacy_content!(tag_name:, body:, created_by_user:, message:)
|
||||
end
|
||||
|
||||
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
|
||||
normalised = normalise_body(body)
|
||||
|
||||
page = WikiPage.new(tag_name:,
|
||||
body: normalised,
|
||||
created_user: created_by_user,
|
||||
updated_user: created_by_user)
|
||||
|
||||
if normalised.blank?
|
||||
page.errors.add(:body, :blank)
|
||||
raise ActiveRecord::RecordInvalid, page
|
||||
end
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
page.save!
|
||||
|
||||
new(page:, created_user: created_by_user).content!(
|
||||
body: normalised,
|
||||
message:,
|
||||
base_revision_id: nil)
|
||||
|
||||
page
|
||||
end
|
||||
end
|
||||
|
||||
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
|
||||
description = TagDescription.find_by(id: page.id)
|
||||
if description
|
||||
return TagDescriptionOperation.update!(
|
||||
tag: description.tag, body:, updated_by_user: created_user,
|
||||
message:, base_revision_id:)
|
||||
end
|
||||
|
||||
legacy_content!(page:, body:, created_user:, message:, base_revision_id:)
|
||||
end
|
||||
|
||||
def self.legacy_content! page:, body:, created_user:, message: nil,
|
||||
base_revision_id: nil
|
||||
new(page:, created_user:).content!(body:, message:, base_revision_id:)
|
||||
end
|
||||
|
||||
def self.redirect! page:, redirect_page:, created_user:, message: nil, base_revision_id: nil
|
||||
new(page:, created_user:).redirect!(redirect_page:, message:, base_revision_id:)
|
||||
end
|
||||
|
||||
def initialize page:, created_user:
|
||||
@page = page
|
||||
@created_user = created_user
|
||||
end
|
||||
|
||||
def content! body:, message:, base_revision_id:
|
||||
normalised = self.class.normalise_body(body)
|
||||
if normalised.blank?
|
||||
@page.errors.add(:body, :blank)
|
||||
raise ActiveRecord::RecordInvalid, @page
|
||||
end
|
||||
|
||||
lines = split_lines(normalised)
|
||||
|
||||
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
|
||||
tree_sha = Digest::SHA256.hexdigest(line_shas.join(','))
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@page.lock!
|
||||
|
||||
if base_revision_id.present?
|
||||
current_id = @page.wiki_revisions.maximum(:id)
|
||||
if current_id && current_id != base_revision_id.to_i
|
||||
raise Conflict,
|
||||
"競合が発生してゐます" +
|
||||
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
|
||||
end
|
||||
end
|
||||
|
||||
line_id_by_sha = upsert_lines!(lines, line_shas)
|
||||
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
|
||||
@page.update!(body: normalised)
|
||||
|
||||
WikiVersionRecorder.record!(
|
||||
page: @page,
|
||||
event_type: @page.wiki_versions.exists? ? :update : :create,
|
||||
reason: message,
|
||||
created_by_user: @created_user)
|
||||
|
||||
rev = WikiRevision.create!(
|
||||
wiki_page: @page,
|
||||
base_revision_id:,
|
||||
created_user: @created_user,
|
||||
kind: :content,
|
||||
redirect_page_id: nil,
|
||||
message:,
|
||||
lines_count: lines.length,
|
||||
tree_sha256: tree_sha)
|
||||
|
||||
rows = line_ids.each_with_index.map do |line_id, pos|
|
||||
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
|
||||
end
|
||||
WikiRevisionLine.insert_all!(rows) if rows.any?
|
||||
rev
|
||||
end
|
||||
end
|
||||
|
||||
def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.'
|
||||
|
||||
def self.normalise_body body
|
||||
s = body.to_s
|
||||
s.gsub!(/\r\n?/, "\n")
|
||||
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
|
||||
s.gsub(/\n+$/, '')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def split_lines(body) = body.split("\n")
|
||||
|
||||
def upsert_lines! lines, line_shas
|
||||
now = Time.current
|
||||
|
||||
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
|
||||
|
||||
missing_by_sha = { }
|
||||
|
||||
line_shas.each_with_index do |sha, i|
|
||||
next if id_by_sha.key?(sha)
|
||||
next if missing_by_sha.key?(sha)
|
||||
|
||||
missing_by_sha[sha] = {
|
||||
sha256: sha,
|
||||
body: lines[i],
|
||||
created_at: now,
|
||||
updated_at: now }
|
||||
end
|
||||
|
||||
if missing_by_sha.any?
|
||||
WikiLine.upsert_all(missing_by_sha.values)
|
||||
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
|
||||
end
|
||||
|
||||
id_by_sha
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,33 +0,0 @@
|
||||
module Wiki
|
||||
class CompatibilityWriter
|
||||
def self.create! tag_name:, body:, created_by_user:, message: nil
|
||||
Commit.create_legacy_content!(
|
||||
tag_name:, body:, created_by_user:, message:)
|
||||
end
|
||||
|
||||
def self.update! page:, body:, updated_by_user:, message: nil,
|
||||
base_revision_id: nil
|
||||
normalised = Commit.normalise_body(body)
|
||||
if normalised.blank?
|
||||
return Commit.legacy_content!(
|
||||
page:, body: normalised, created_user: updated_by_user, message:,
|
||||
base_revision_id:)
|
||||
end
|
||||
|
||||
if base_revision_id.present?
|
||||
current_id = page.wiki_revisions.maximum(:id)
|
||||
if current_id && current_id != base_revision_id.to_i
|
||||
raise Commit::Conflict,
|
||||
"競合が発生してゐます" +
|
||||
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
|
||||
end
|
||||
end
|
||||
|
||||
return if Commit.normalise_body(page.body) == normalised
|
||||
|
||||
Commit.legacy_content!(
|
||||
page:, body: normalised, created_user: updated_by_user, message:,
|
||||
base_revision_id:)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,21 +0,0 @@
|
||||
class WikiVersionRecorder < VersionRecorder
|
||||
def self.record! page:, event_type:, reason: nil, created_by_user:
|
||||
new(page:, event_type:, reason:, created_by_user:).record!
|
||||
end
|
||||
|
||||
def initialize page:, event_type:, reason: nil, created_by_user:
|
||||
@reason = reason
|
||||
super(record: page, event_type:, created_by_user:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def version_class = WikiVersion
|
||||
def version_association = :wiki_versions
|
||||
def record_key = :wiki_page
|
||||
|
||||
def snapshot_attributes = {
|
||||
title: @record.title,
|
||||
body: @record.body,
|
||||
reason: @reason }
|
||||
end
|
||||
@@ -1,4 +1,8 @@
|
||||
Rails.application.routes.draw do
|
||||
resources :locales, only: [:index]
|
||||
|
||||
resources :tag_groups, only: [:index, :show, :create, :update]
|
||||
|
||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||
|
||||
scope 'tags/:parent_id/children', controller: :tag_children do
|
||||
@@ -41,6 +45,7 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
get :search, action: :index
|
||||
get :changes
|
||||
get :autolink_candidates
|
||||
|
||||
scope :title do
|
||||
get ':title/exists', action: :exists_by_title
|
||||
|
||||
ファイル差分が大きすぎるため省略します
差分を読み込み
@@ -0,0 +1,149 @@
|
||||
class CreateTagGroups < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
# 翻訳系は上流の Translation リソースが未完のため保留
|
||||
|
||||
create_table :tag_groups do |t|
|
||||
t.integer :version_no, null: false
|
||||
t.string :name, limit: 255, null: false
|
||||
t.text :description
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||
t.references :updated_by_user, foreign_key: { to_table: :users }
|
||||
t.timestamps
|
||||
|
||||
t.check_constraint 'version_no > 0', name: 'chk_tag_groups_version_no_positive'
|
||||
t.index :name, unique: true
|
||||
end
|
||||
|
||||
create_table :tag_group_tags, primary_key: [:tag_group_id, :tag_id] do |t|
|
||||
t.references :tag_group, null: false, foreign_key: true, index: false
|
||||
t.references :tag, null: false, foreign_key: true
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||
t.datetime :created_at, null: false
|
||||
end
|
||||
|
||||
create_table :tag_group_groups, primary_key: [:tag_group_id, :child_tag_group_id] do |t|
|
||||
t.references :tag_group, null: false, foreign_key: true, index: false
|
||||
t.references :child_tag_group,
|
||||
null: false, foreign_key: { to_table: :tag_groups }
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||
t.datetime :created_at, null: false
|
||||
|
||||
t.check_constraint 'tag_group_id <> child_tag_group_id',
|
||||
name: 'chk_tag_group_groups_tag_group_id_not_child_tag_group_id'
|
||||
end
|
||||
|
||||
create_table :tag_group_placements, primary_key: [:tag_group_id, :position] do |t|
|
||||
t.references :tag_group, null: false, foreign_key: true, index: false
|
||||
t.integer :position, null: false
|
||||
t.integer :parent_position
|
||||
t.string :section_name, limit: 255
|
||||
t.text :description
|
||||
t.references :tag
|
||||
t.references :child_tag_group
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||
t.references :updated_by_user, foreign_key: { to_table: :users }
|
||||
t.timestamps
|
||||
|
||||
t.foreign_key :tag_group_placements,
|
||||
column: [:tag_group_id, :parent_position],
|
||||
primary_key: [:tag_group_id, :position]
|
||||
|
||||
t.foreign_key :tag_group_tags,
|
||||
column: [:tag_group_id, :tag_id],
|
||||
primary_key: [:tag_group_id, :tag_id]
|
||||
|
||||
t.foreign_key :tag_group_groups,
|
||||
column: [:tag_group_id, :child_tag_group_id],
|
||||
primary_key: [:tag_group_id, :child_tag_group_id]
|
||||
|
||||
t.check_constraint 'position > parent_position',
|
||||
name: 'chk_tag_group_placements_position_gt_parent_position'
|
||||
|
||||
t.check_constraint ('((section_name IS NOT NULL AND tag_id IS NULL AND child_tag_group_id IS NULL) OR ' +
|
||||
'(section_name IS NULL AND tag_id IS NOT NULL AND child_tag_group_id IS NULL) OR ' +
|
||||
'(section_name IS NULL AND tag_id IS NULL AND child_tag_group_id IS NOT NULL)) AND ' +
|
||||
'(section_name IS NOT NULL OR description IS NULL)'),
|
||||
name: 'chk_tag_group_placements_references_exclusive'
|
||||
end
|
||||
|
||||
create_table :tag_group_versions do |t|
|
||||
t.references :tag_group, null: false
|
||||
t.integer :version_no, null: false
|
||||
t.string :event_type, null: false
|
||||
t.string :name, limit: 255, null: false
|
||||
t.text :description
|
||||
t.json :members, null: false
|
||||
t.json :placements, null: false
|
||||
t.references :created_by_user
|
||||
t.datetime :created_at, null: false
|
||||
|
||||
t.check_constraint 'version_no > 0',
|
||||
name: 'chk_tag_group_versions_version_no_positive'
|
||||
t.index [:tag_group_id, :version_no], unique: true
|
||||
|
||||
add_members_constraint!(t)
|
||||
add_placements_constraint!(t)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_members_constraint! t
|
||||
schema = { type: 'array',
|
||||
uniqueItems: true,
|
||||
items: member_schema }
|
||||
|
||||
quoted_schema = connection.quote(JSON.generate(schema))
|
||||
|
||||
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, members)",
|
||||
name: 'chk_tag_group_versions_members_schema'
|
||||
end
|
||||
|
||||
def member_schema
|
||||
{ oneOf: [tag_member_schema, group_member_schema] }
|
||||
end
|
||||
|
||||
def tag_member_schema
|
||||
{ type: 'object',
|
||||
required: ['tag_id', 'version_no'],
|
||||
properties: { tag_id: { type: 'integer', minimum: 1 },
|
||||
version_no: { type: 'integer', minimum: 1 } },
|
||||
additionalProperties: false }
|
||||
end
|
||||
|
||||
def group_member_schema
|
||||
{ type: 'object',
|
||||
required: ['tag_group_id', 'version_no'],
|
||||
properties: { tag_group_id: { type: 'integer', minimum: 1 },
|
||||
version_no: { type: 'integer', minimum: 1 } },
|
||||
additionalProperties: false }
|
||||
end
|
||||
|
||||
def add_placements_constraint! t
|
||||
schema = { type: 'array', items: placement_schema(5) }
|
||||
|
||||
quoted_schema = connection.quote(JSON.generate(schema))
|
||||
|
||||
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, placements)",
|
||||
name: 'chk_tag_group_versions_placements_schema'
|
||||
end
|
||||
|
||||
def placement_schema rest
|
||||
{ type: 'object',
|
||||
required: ['children'],
|
||||
properties: { section_name: { type: 'string' },
|
||||
description: { type: ['string', 'null'] },
|
||||
tag_id: { type: 'integer', minimum: 1 },
|
||||
tag_group_id: { type: 'integer', minimum: 1 },
|
||||
children: if rest > 1
|
||||
{ type: 'array', items: placement_schema(rest - 1) }
|
||||
else
|
||||
{ type: 'array', maxItems: 0 }
|
||||
end },
|
||||
oneOf: [{ required: ['section_name'] },
|
||||
{ required: ['tag_id'] },
|
||||
{ required: ['tag_group_id'] }],
|
||||
dependencies: { description: ['section_name'] },
|
||||
additionalProperties: false }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,518 @@
|
||||
class MigrateTagGroupsFromWikiPages < ActiveRecord::Migration[8.0]
|
||||
PREFIX = 'タググループ:'
|
||||
|
||||
def up
|
||||
require 'json'
|
||||
require 'time'
|
||||
require 'kramdown'
|
||||
require 'kramdown-parser-gfm'
|
||||
|
||||
assert_empty_destination!
|
||||
|
||||
pages = connection.select_all(<<~SQL).to_a
|
||||
SELECT
|
||||
wp.id
|
||||
, wp.created_user_id
|
||||
, wp.updated_user_id
|
||||
, wp.created_at
|
||||
, wp.updated_at
|
||||
, wp.discarded_at
|
||||
, tn.name
|
||||
FROM
|
||||
wiki_pages wp
|
||||
INNER JOIN
|
||||
tag_names tn
|
||||
ON
|
||||
tn.id = wp.tag_name_id
|
||||
WHERE
|
||||
tn.language_code = 'ja'
|
||||
AND tn.name LIKE #{ quote("#{ PREFIX }%") }
|
||||
ORDER BY
|
||||
wp.id
|
||||
SQL
|
||||
|
||||
return if pages.empty?
|
||||
|
||||
pages.each do |page|
|
||||
if page['discarded_at']
|
||||
say "Wiki ##{ page.fetch('id') }: 破棄済みですが,Revision の移行を継続します."
|
||||
end
|
||||
end
|
||||
|
||||
tag_ids = load_tag_ids
|
||||
page_ids = pages.map { |page| page.fetch('id').to_i }
|
||||
|
||||
revisions = connection.select_all(<<~SQL).to_a
|
||||
SELECT
|
||||
wr.id
|
||||
, wr.wiki_page_id
|
||||
, wr.kind
|
||||
, wr.redirect_page_id
|
||||
, wr.created_user_id
|
||||
, wr.created_at
|
||||
FROM
|
||||
wiki_revisions wr
|
||||
WHERE
|
||||
wr.wiki_page_id IN (#{ page_ids.join(', ') })
|
||||
ORDER BY
|
||||
wr.wiki_page_id
|
||||
, wr.id
|
||||
SQL
|
||||
|
||||
revisions_by_page = revisions.group_by { |row| row.fetch('wiki_page_id').to_i }
|
||||
|
||||
pages = pages.select do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
if revisions_by_page.fetch(page_id, []).empty?
|
||||
# tag_groups.version_no > 0 のため,Revision 0 件の Wiki は作成しない.
|
||||
say "Wiki ##{ page_id }: Revision が 0 件のため移行対象から除外します."
|
||||
false
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
return if pages.empty?
|
||||
|
||||
group_ids = pages.to_h { |page| [page.fetch('name'), page.fetch('id').to_i] }
|
||||
revision_history = pages.to_h do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
history = revisions_by_page.fetch(page_id).each_with_index.map do |revision, index|
|
||||
{ 'id' => revision.fetch('id').to_i,
|
||||
'version_no' => index + 1,
|
||||
'created_at' => revision.fetch('created_at') }
|
||||
end
|
||||
[page_id, history]
|
||||
end
|
||||
|
||||
tag_history = connection.select_all(<<~SQL).to_a.group_by { |row| row.fetch('tag_id').to_i }
|
||||
SELECT
|
||||
tag_id
|
||||
, version_no
|
||||
, created_at
|
||||
FROM
|
||||
tag_versions
|
||||
ORDER BY
|
||||
tag_id
|
||||
, created_at
|
||||
, version_no
|
||||
SQL
|
||||
|
||||
prepared = { }
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
history = revisions_by_page.fetch(page_id)
|
||||
|
||||
prepared[page_id] = history.each_with_index.map do |revision, index|
|
||||
context = "Wiki ##{ page_id } / Revision ##{ revision.fetch('id') }"
|
||||
body = revision_body(revision.fetch('id').to_i)
|
||||
|
||||
if revision.fetch('kind').to_i == 1
|
||||
destination = revision['redirect_page_id']
|
||||
description = "旧 Wiki のリダイレクト先: Wiki ##{ destination }"
|
||||
placements = []
|
||||
members = []
|
||||
say "#{ context }: リダイレクトを説明文として移行します."
|
||||
else
|
||||
begin
|
||||
description, placements = parse_wiki(body, tag_ids, group_ids, context)
|
||||
validate_tree!(placements, context)
|
||||
members = build_members(placements, revision.fetch('created_at'),
|
||||
tag_history, revision_history, context)
|
||||
rescue StandardError => error
|
||||
# 解析できない版も捨てず,当時の Markdown を説明文として保存する.
|
||||
say "#{ context }: 構造化を保留し,本文を保存します (#{ error.message })."
|
||||
description = body
|
||||
placements = []
|
||||
members = []
|
||||
end
|
||||
end
|
||||
|
||||
{ revision:, version_no: index + 1, description:, members:, placements:, body: }
|
||||
end
|
||||
end
|
||||
|
||||
# 自己包含は禁止されてゐるため,当該版は原文の保存を優先する.
|
||||
prepared.each do |group_id, versions|
|
||||
current = versions.last
|
||||
next unless current.fetch(:members).any? { |member| member['tag_group_id'] == group_id }
|
||||
|
||||
say "Tag Group ##{ group_id }: 直接の自己参照を検出したため,現行構造化を保留します."
|
||||
current[:description] = current.fetch(:body)
|
||||
current[:members] = []
|
||||
current[:placements] = []
|
||||
end
|
||||
|
||||
begin
|
||||
validate_current_groups!(prepared)
|
||||
rescue StandardError => error
|
||||
say "Tag Group の包含関係に問題がありますが,移行を継続します (#{ error.message })."
|
||||
end
|
||||
|
||||
# 移行先の書込みはすべて検証の後.DDL は含めない.
|
||||
connection.transaction do
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
current = prepared.fetch(page_id).last
|
||||
|
||||
insert_row('tag_groups', {
|
||||
id: page_id,
|
||||
# page.version_no ではなく,この Wiki に紐付く Revision の総数.
|
||||
version_no: revisions_by_page.fetch(page_id).size,
|
||||
name: page.fetch('name').delete_prefix(PREFIX),
|
||||
description: current.fetch(:description),
|
||||
created_by_user_id: page.fetch('created_user_id'),
|
||||
updated_by_user_id: page.fetch('updated_user_id'),
|
||||
created_at: page.fetch('created_at'),
|
||||
updated_at: page.fetch('updated_at') })
|
||||
end
|
||||
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
name = page.fetch('name').delete_prefix(PREFIX)
|
||||
|
||||
prepared.fetch(page_id).each do |item|
|
||||
revision = item.fetch(:revision)
|
||||
|
||||
insert_row('tag_group_versions', {
|
||||
id: revision.fetch('id'),
|
||||
tag_group_id: page_id,
|
||||
version_no: item.fetch(:version_no),
|
||||
event_type: item.fetch(:version_no) == 1 ? 'create' : 'update',
|
||||
name:,
|
||||
description: item.fetch(:description),
|
||||
members: JSON.generate(item.fetch(:members)),
|
||||
placements: JSON.generate(item.fetch(:placements)),
|
||||
created_by_user_id: revision.fetch('created_user_id'),
|
||||
created_at: revision.fetch('created_at') })
|
||||
end
|
||||
end
|
||||
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
current = prepared.fetch(page_id).last
|
||||
revision = current.fetch(:revision)
|
||||
|
||||
current.fetch(:members).each do |member|
|
||||
if member.key?('tag_id')
|
||||
insert_row('tag_group_tags', {
|
||||
tag_group_id: page_id,
|
||||
tag_id: member.fetch('tag_id'),
|
||||
created_by_user_id: revision.fetch('created_user_id'),
|
||||
created_at: revision.fetch('created_at') })
|
||||
else
|
||||
insert_row('tag_group_groups', {
|
||||
tag_group_id: page_id,
|
||||
child_tag_group_id: member.fetch('tag_group_id'),
|
||||
created_by_user_id: revision.fetch('created_user_id'),
|
||||
created_at: revision.fetch('created_at') })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
pages.each do |page|
|
||||
page_id = page.fetch('id').to_i
|
||||
current = prepared.fetch(page_id).last
|
||||
revision = current.fetch(:revision)
|
||||
position = 0
|
||||
|
||||
append = -> nodes, parent_position do
|
||||
nodes.each do |node|
|
||||
position += 1
|
||||
own_position = position
|
||||
|
||||
insert_row('tag_group_placements', {
|
||||
tag_group_id: page_id,
|
||||
position: own_position,
|
||||
parent_position:,
|
||||
section_name: node['section_name'],
|
||||
description: node['description'],
|
||||
tag_id: node['tag_id'],
|
||||
child_tag_group_id: node['tag_group_id'],
|
||||
created_by_user_id: revision.fetch('created_user_id'),
|
||||
updated_by_user_id: revision.fetch('created_user_id'),
|
||||
created_at: revision.fetch('created_at'),
|
||||
updated_at: revision.fetch('created_at') })
|
||||
|
||||
append.(node.fetch('children'), own_position)
|
||||
end
|
||||
end
|
||||
|
||||
append.(current.fetch(:placements), nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_empty_destination!
|
||||
['tag_groups', 'tag_group_versions', 'tag_group_tags',
|
||||
'tag_group_groups', 'tag_group_placements'].each do |table|
|
||||
count = connection.select_value("SELECT COUNT(*) FROM #{ table }").to_i
|
||||
raise "#{ table } が空ではありません." if count.positive?
|
||||
end
|
||||
end
|
||||
|
||||
# 解決は tag_names.name -> tag_names.tag_id のみ.
|
||||
# tags.tag_name_id は読まない.
|
||||
def load_tag_ids
|
||||
rows = connection.select_all(<<~SQL).to_a
|
||||
SELECT
|
||||
name
|
||||
, tag_id
|
||||
FROM
|
||||
tag_names
|
||||
WHERE
|
||||
language_code = 'ja'
|
||||
AND tag_id IS NOT NULL
|
||||
SQL
|
||||
|
||||
rows.each_with_object({ }) do |row, by_name|
|
||||
name = row.fetch('name')
|
||||
id = row.fetch('tag_id').to_i
|
||||
by_name[name] ||= []
|
||||
by_name[name] << id unless by_name[name].include?(id)
|
||||
end
|
||||
end
|
||||
|
||||
def revision_body revision_id
|
||||
connection.select_rows(<<~SQL).flatten.join("\n")
|
||||
SELECT
|
||||
wl.body
|
||||
FROM
|
||||
wiki_revision_lines wrl
|
||||
INNER JOIN wiki_lines wl ON wl.id = wrl.wiki_line_id
|
||||
WHERE
|
||||
wrl.wiki_revision_id = #{ Integer(revision_id) }
|
||||
ORDER BY
|
||||
wrl.position
|
||||
SQL
|
||||
end
|
||||
|
||||
def parse_wiki markdown, tag_ids, group_ids, context
|
||||
root = Kramdown::Document.new(markdown, input: 'GFM').root
|
||||
placements = []
|
||||
description_parts = []
|
||||
headings = []
|
||||
|
||||
root.children.each_with_index do |node, index|
|
||||
case node.type
|
||||
when :blank
|
||||
next
|
||||
when :header
|
||||
level = node.options.fetch(:level)
|
||||
name = inline_text(node).strip
|
||||
raise "#{ context }: 名前のない見出しがあります." if name.empty?
|
||||
|
||||
headings.pop while headings.any? && headings.last.first >= level
|
||||
parent = headings.last&.last
|
||||
section = { 'section_name' => name, 'description' => nil, 'children' => [] }
|
||||
(parent ? parent.fetch('children') : placements) << section
|
||||
headings << [level, section]
|
||||
when :p
|
||||
text = inline_markdown(node).strip
|
||||
next if text.empty?
|
||||
|
||||
if headings.any?
|
||||
heading = headings.last.last
|
||||
heading['description'] = [heading['description'], text].compact.join("\n\n")
|
||||
else
|
||||
description_parts << text
|
||||
end
|
||||
when :ul, :ol
|
||||
if headings.any? && see_also_heading?(headings.last.last.fetch('section_name'))
|
||||
heading = headings.last.last
|
||||
raw = original_block(markdown, root.children, index)
|
||||
heading['description'] = [heading['description'], raw].compact.join("\n\n")
|
||||
else
|
||||
target = headings.any? ? headings.last.last.fetch('children') : placements
|
||||
target.concat(node.children.map { |item| parse_list_item(item, tag_ids, group_ids, context) })
|
||||
end
|
||||
else
|
||||
raise "#{ context }: 未対応の Markdown 構文 #{ node.type.inspect }"
|
||||
end
|
||||
end
|
||||
|
||||
[description_parts.empty? ? nil : description_parts.join("\n\n"), placements]
|
||||
end
|
||||
|
||||
def see_also_heading? name
|
||||
['see also', '関連項目'].include?(name.strip.downcase.sub(/[::]\z/, ''))
|
||||
end
|
||||
|
||||
def original_block markdown, siblings, index
|
||||
current = siblings.fetch(index)
|
||||
start_line = current.options[:location]
|
||||
raise 'Markdown block の位置が不明です.' unless start_line
|
||||
|
||||
following = siblings[(index + 1)..].find do |node|
|
||||
node.options[:location] && node.options[:location] > start_line
|
||||
end
|
||||
end_line = following ? following.options.fetch(:location) - 1 : markdown.lines.length
|
||||
markdown.lines[(start_line - 1)...end_line].join.strip
|
||||
end
|
||||
|
||||
def parse_list_item item, tag_ids, group_ids, context
|
||||
raise "#{ context }: リスト項目ではありません." if item.type != :li
|
||||
|
||||
nested, own = item.children.partition { |child| [:ul, :ol].include?(child.type) }
|
||||
own.reject! { |child| child.type == :blank }
|
||||
unless own.all? { |child| [:p, :text, :a, :codespan].include?(child.type) }
|
||||
raise "#{ context }: 未対応のリスト項目があります."
|
||||
end
|
||||
|
||||
label = own.map { |child| inline_text(child) }.join(' ').strip
|
||||
raise "#{ context }: 空のリスト項目があります." if label.empty?
|
||||
|
||||
node =
|
||||
if group_ids.key?(label)
|
||||
{ 'tag_group_id' => group_ids.fetch(label) }
|
||||
elsif tag_ids.key?(label)
|
||||
ids = tag_ids.fetch(label)
|
||||
unless ids.one?
|
||||
raise "#{ context }: タグ名 #{ label.inspect } が複数の ID を指します:#{ ids.inspect }"
|
||||
end
|
||||
{ 'tag_id' => ids.first }
|
||||
elsif label.end_with?(':', ':')
|
||||
name = label.sub(/[::]\z/, '')
|
||||
raise "#{ context }: 空の区分名があります." if name.empty?
|
||||
if see_also_heading?(name) && nested.any?
|
||||
raise "#{ context }: リスト内の See also は説明文への保管処理が未実装です."
|
||||
end
|
||||
{ 'section_name' => name, 'description' => nil }
|
||||
else
|
||||
raise "#{ context }: 解決不能なリスト項目 #{ label.inspect }"
|
||||
end
|
||||
|
||||
node['children'] = nested.flat_map do |list|
|
||||
list.children.map { |child| parse_list_item(child, tag_ids, group_ids, context) }
|
||||
end
|
||||
|
||||
node
|
||||
end
|
||||
|
||||
def inline_text node
|
||||
case node.type
|
||||
when :text, :codespan
|
||||
node.value.to_s
|
||||
when :softbreak, :br
|
||||
' '
|
||||
when :a, :p, :header, :em, :strong, :span, :li
|
||||
node.children.map { |child| inline_text(child) }.join
|
||||
else
|
||||
raise "未対応のインライン構文 #{ node.type.inspect }"
|
||||
end
|
||||
end
|
||||
|
||||
def inline_markdown node
|
||||
case node.type
|
||||
when :text
|
||||
node.value.to_s
|
||||
when :codespan
|
||||
"`#{ node.value }`"
|
||||
when :softbreak
|
||||
"\n"
|
||||
when :br
|
||||
" \n"
|
||||
when :p
|
||||
node.children.map { |child| inline_markdown(child) }.join
|
||||
when :a
|
||||
href = node.attr.fetch('href')
|
||||
"[#{ node.children.map { |child| inline_markdown(child) }.join }](#{ href })"
|
||||
when :em
|
||||
"*#{ node.children.map { |child| inline_markdown(child) }.join }*"
|
||||
when :strong
|
||||
"**#{ node.children.map { |child| inline_markdown(child) }.join }**"
|
||||
else
|
||||
raise "未対応のインライン構文 #{ node.type.inspect }"
|
||||
end
|
||||
end
|
||||
|
||||
def validate_tree! nodes, context, depth = 1
|
||||
raise "#{ context }: 配置の深さが 5 を超えてゐます." if depth > 5 && nodes.any?
|
||||
|
||||
nodes.each do |node|
|
||||
name = node['section_name']
|
||||
if name && name.length > 255
|
||||
raise "#{ context }: 区分名が 255 文字を超えてゐます."
|
||||
end
|
||||
validate_tree!(node.fetch('children'), context, depth + 1)
|
||||
end
|
||||
end
|
||||
|
||||
def build_members nodes, date, tag_history, group_history, context
|
||||
ids_of_tags = []
|
||||
ids_of_groups = []
|
||||
|
||||
visit = -> items do
|
||||
items.each do |node|
|
||||
ids_of_tags << node['tag_id'] if node.key?('tag_id')
|
||||
ids_of_groups << node['tag_group_id'] if node.key?('tag_group_id')
|
||||
visit.(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
visit.(nodes)
|
||||
|
||||
tags = ids_of_tags.uniq.map do |id|
|
||||
{ 'tag_id' => id,
|
||||
'version_no' => version_at(tag_history[id], date, context, "Tag ##{ id }") }
|
||||
end
|
||||
|
||||
groups = ids_of_groups.uniq.map do |id|
|
||||
{ 'tag_group_id' => id,
|
||||
'version_no' => version_at(group_history[id], date, context, "TagGroup ##{ id }") }
|
||||
end
|
||||
|
||||
tags + groups
|
||||
end
|
||||
|
||||
def version_at history, date, context, target
|
||||
raise "#{ context }: #{ target } の履歴が存在しません." unless history&.any?
|
||||
|
||||
timestamp = Time.parse(date.to_s)
|
||||
candidates = history.select do |row|
|
||||
Time.parse(row.fetch('created_at').to_s) <= timestamp
|
||||
end
|
||||
if candidates.empty?
|
||||
raise "#{ context }: #{ target } は #{ date } 時点で存在しません."
|
||||
end
|
||||
|
||||
candidates.max_by { |row|
|
||||
[Time.parse(row.fetch('created_at').to_s), row.fetch('version_no').to_i]
|
||||
}.fetch('version_no').to_i
|
||||
end
|
||||
|
||||
def validate_current_groups! prepared
|
||||
edges = { }
|
||||
prepared.each do |group_id, versions|
|
||||
edges[group_id] = versions.last.fetch(:members)
|
||||
.filter_map { |member| member['tag_group_id'] }
|
||||
end
|
||||
|
||||
visiting = { }
|
||||
visited = { }
|
||||
visit = -> group_id do
|
||||
raise "Tag Group の循環参照を検出しました:#{ group_id }" if visiting[group_id]
|
||||
return if visited[group_id]
|
||||
|
||||
visiting[group_id] = true
|
||||
edges.fetch(group_id, []).each { |child_id| visit.(child_id) }
|
||||
visiting.delete(group_id)
|
||||
visited[group_id] = true
|
||||
end
|
||||
|
||||
edges.keys.each { |group_id| visit.(group_id) }
|
||||
end
|
||||
|
||||
def quote(value) = connection.quote(value)
|
||||
|
||||
def insert_row table, values
|
||||
columns = values.keys.join(', ')
|
||||
payload = values.values.map { |value| quote(value) }.join(', ')
|
||||
execute "INSERT INTO #{ table } (#{ columns }) VALUES (#{ payload })"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class DropWikiPages < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
drop_table :wiki_versions
|
||||
drop_table :wiki_revision_lines
|
||||
drop_table :wiki_lines
|
||||
drop_table :wiki_revisions
|
||||
drop_table :wiki_pages
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class RemoveTagNameIdFromTags < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
remove_foreign_key :tags, column: :tag_name_id
|
||||
remove_foreign_key :tag_names, column: :canonical_id
|
||||
|
||||
remove_column :tags, :tag_name_id
|
||||
remove_column :tag_names, :canonical_id
|
||||
end
|
||||
|
||||
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class DeleteNameForWikiFromTagNames < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
execute <<~SQL
|
||||
DELETE
|
||||
FROM
|
||||
tag_names
|
||||
WHERE
|
||||
tag_id IS NULL
|
||||
SQL
|
||||
|
||||
change_column_null :tag_names, :tag_id, false
|
||||
end
|
||||
|
||||
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||
end
|
||||
生成ファイル
+90
-89
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -483,6 +483,80 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "tag_group_groups", primary_key: ["tag_group_id", "child_tag_group_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_group_id", null: false
|
||||
t.bigint "child_tag_group_id", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["child_tag_group_id"], name: "index_tag_group_groups_on_child_tag_group_id"
|
||||
t.index ["created_by_user_id"], name: "index_tag_group_groups_on_created_by_user_id"
|
||||
t.check_constraint "`tag_group_id` <> `child_tag_group_id`", name: "chk_tag_group_groups_tag_group_id_not_child_tag_group_id"
|
||||
end
|
||||
|
||||
create_table "tag_group_placements", primary_key: ["tag_group_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_group_id", null: false
|
||||
t.integer "position", null: false
|
||||
t.integer "parent_position"
|
||||
t.string "section_name"
|
||||
t.text "description"
|
||||
t.bigint "tag_id"
|
||||
t.bigint "child_tag_group_id"
|
||||
t.bigint "created_by_user_id"
|
||||
t.bigint "updated_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["child_tag_group_id"], name: "index_tag_group_placements_on_child_tag_group_id"
|
||||
t.index ["created_by_user_id"], name: "index_tag_group_placements_on_created_by_user_id"
|
||||
t.index ["tag_group_id", "child_tag_group_id"], name: "fk_rails_7342b54ced"
|
||||
t.index ["tag_group_id", "parent_position"], name: "fk_rails_3af6ee71ca"
|
||||
t.index ["tag_group_id", "tag_id"], name: "fk_rails_365dc60759"
|
||||
t.index ["tag_id"], name: "index_tag_group_placements_on_tag_id"
|
||||
t.index ["updated_by_user_id"], name: "index_tag_group_placements_on_updated_by_user_id"
|
||||
t.check_constraint "(((`section_name` is not null) and (`tag_id` is null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is not null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is null) and (`child_tag_group_id` is not null))) and ((`section_name` is not null) or (`description` is null))", name: "chk_tag_group_placements_references_exclusive"
|
||||
t.check_constraint "`position` > `parent_position`", name: "chk_tag_group_placements_position_gt_parent_position"
|
||||
end
|
||||
|
||||
create_table "tag_group_tags", primary_key: ["tag_group_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_group_id", null: false
|
||||
t.bigint "tag_id", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["created_by_user_id"], name: "index_tag_group_tags_on_created_by_user_id"
|
||||
t.index ["tag_id"], name: "index_tag_group_tags_on_tag_id"
|
||||
end
|
||||
|
||||
create_table "tag_group_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_group_id", null: false
|
||||
t.integer "version_no", null: false
|
||||
t.string "event_type", null: false
|
||||
t.string "name", null: false
|
||||
t.text "description"
|
||||
t.json "members", null: false
|
||||
t.json "placements", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id"
|
||||
t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true
|
||||
t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive"
|
||||
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"maxItems\":0}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}',`placements`)", name: "chk_tag_group_versions_placements_schema"
|
||||
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"uniqueItems\":true,\"items\":{\"oneOf\":[{\"type\":\"object\",\"required\":[\"tag_id\",\"version_no\"],\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false},{\"type\":\"object\",\"required\":[\"tag_group_id\",\"version_no\"],\"properties\":{\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false}]}}',`members`)", name: "chk_tag_group_versions_members_schema"
|
||||
end
|
||||
|
||||
create_table "tag_groups", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.integer "version_no", null: false
|
||||
t.string "name", null: false
|
||||
t.text "description"
|
||||
t.bigint "created_by_user_id"
|
||||
t.bigint "updated_by_user_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["created_by_user_id"], name: "index_tag_groups_on_created_by_user_id"
|
||||
t.index ["name"], name: "index_tag_groups_on_name", unique: true
|
||||
t.index ["updated_by_user_id"], name: "index_tag_groups_on_updated_by_user_id"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tag_groups_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_id", null: false
|
||||
t.bigint "parent_tag_id", null: false
|
||||
@@ -504,17 +578,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
end
|
||||
|
||||
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_id"
|
||||
t.bigint "tag_id", null: false
|
||||
t.string "language_code", limit: 16, null: false
|
||||
t.string "name", null: false
|
||||
t.string "script_code", limit: 4, null: false
|
||||
t.boolean "primary_flg", null: false
|
||||
t.boolean "auto_generated", default: false, null: false
|
||||
t.bigint "canonical_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)"
|
||||
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
|
||||
t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true
|
||||
t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true
|
||||
t.index ["script_code"], name: "fk_rails_dd783b3d1c"
|
||||
@@ -551,7 +623,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
end
|
||||
|
||||
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_name_id", null: false
|
||||
t.string "category", default: "general", null: false
|
||||
t.datetime "deprecated_at"
|
||||
t.datetime "created_at", null: false
|
||||
@@ -559,7 +630,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
t.integer "post_count", default: 0, null: false
|
||||
t.integer "version_no", null: false
|
||||
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
||||
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
|
||||
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
||||
end
|
||||
@@ -699,76 +769,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
t.index ["banned_at"], name: "index_users_on_banned_at"
|
||||
end
|
||||
|
||||
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "sha256", limit: 64, null: false
|
||||
t.text "body", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["sha256"], name: "index_wiki_lines_on_sha256", unique: true
|
||||
end
|
||||
|
||||
create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_name_id", null: false
|
||||
t.text "body", null: false
|
||||
t.bigint "created_user_id", null: false
|
||||
t.bigint "updated_user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "discarded_at"
|
||||
t.integer "version_no", null: false
|
||||
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
|
||||
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
|
||||
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
|
||||
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
|
||||
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "wiki_revision_id", null: false
|
||||
t.integer "position", null: false
|
||||
t.bigint "wiki_line_id", null: false
|
||||
t.index ["wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_line_id"
|
||||
t.index ["wiki_revision_id", "position"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_position", unique: true
|
||||
t.index ["wiki_revision_id", "wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_wiki_line_id"
|
||||
t.index ["wiki_revision_id"], name: "index_wiki_revision_lines_on_wiki_revision_id"
|
||||
end
|
||||
|
||||
create_table "wiki_revisions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "wiki_page_id", null: false
|
||||
t.bigint "base_revision_id"
|
||||
t.bigint "created_user_id", null: false
|
||||
t.integer "kind", default: 0, null: false
|
||||
t.bigint "redirect_page_id"
|
||||
t.string "message"
|
||||
t.integer "lines_count", default: 0, null: false
|
||||
t.string "tree_sha256", limit: 64
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["base_revision_id"], name: "index_wiki_revisions_on_base_revision_id"
|
||||
t.index ["created_user_id"], name: "index_wiki_revisions_on_created_user_id"
|
||||
t.index ["kind"], name: "index_wiki_revisions_on_kind"
|
||||
t.index ["redirect_page_id"], name: "index_wiki_revisions_on_redirect_page_id"
|
||||
t.index ["tree_sha256"], name: "index_wiki_revisions_on_tree_sha256"
|
||||
t.index ["wiki_page_id", "id"], name: "index_wiki_revisions_on_wiki_page_id_and_id"
|
||||
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
|
||||
end
|
||||
|
||||
create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "wiki_page_id", null: false
|
||||
t.integer "version_no", null: false
|
||||
t.string "event_type", null: false
|
||||
t.string "title", null: false
|
||||
t.text "body", null: false
|
||||
t.text "reason"
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
|
||||
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
|
||||
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
|
||||
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
|
||||
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
|
||||
@@ -823,16 +823,28 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
add_foreign_key "tag_descriptions", "tags"
|
||||
add_foreign_key "tag_descriptions", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_descriptions", "users", column: "updated_by_user_id"
|
||||
add_foreign_key "tag_group_groups", "tag_groups"
|
||||
add_foreign_key "tag_group_groups", "tag_groups", column: "child_tag_group_id"
|
||||
add_foreign_key "tag_group_groups", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_group_placements", "tag_group_groups", column: ["tag_group_id", "child_tag_group_id"], primary_key: ["tag_group_id", "child_tag_group_id"]
|
||||
add_foreign_key "tag_group_placements", "tag_group_placements", column: ["tag_group_id", "parent_position"], primary_key: ["tag_group_id", "position"]
|
||||
add_foreign_key "tag_group_placements", "tag_group_tags", column: ["tag_group_id", "tag_id"], primary_key: ["tag_group_id", "tag_id"]
|
||||
add_foreign_key "tag_group_placements", "tag_groups"
|
||||
add_foreign_key "tag_group_placements", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_group_placements", "users", column: "updated_by_user_id"
|
||||
add_foreign_key "tag_group_tags", "tag_groups"
|
||||
add_foreign_key "tag_group_tags", "tags"
|
||||
add_foreign_key "tag_group_tags", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_groups", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tag_groups", "users", column: "updated_by_user_id"
|
||||
add_foreign_key "tag_implications", "tags"
|
||||
add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
|
||||
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
|
||||
add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code"
|
||||
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
|
||||
add_foreign_key "tag_names", "tags"
|
||||
add_foreign_key "tag_similarities", "tags"
|
||||
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
|
||||
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tags", "tag_names"
|
||||
add_foreign_key "theatre_comments", "theatres"
|
||||
add_foreign_key "theatre_comments", "users"
|
||||
add_foreign_key "theatre_programmes", "posts"
|
||||
@@ -857,15 +869,4 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
|
||||
add_foreign_key "user_post_views", "posts"
|
||||
add_foreign_key "user_post_views", "users"
|
||||
add_foreign_key "user_theme_slots", "users"
|
||||
add_foreign_key "wiki_pages", "tag_names"
|
||||
add_foreign_key "wiki_pages", "users", column: "created_user_id"
|
||||
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
|
||||
add_foreign_key "wiki_revision_lines", "wiki_lines"
|
||||
add_foreign_key "wiki_revision_lines", "wiki_revisions"
|
||||
add_foreign_key "wiki_revisions", "users", column: "created_user_id"
|
||||
add_foreign_key "wiki_revisions", "wiki_pages"
|
||||
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
|
||||
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
|
||||
add_foreign_key "wiki_versions", "users", column: "created_by_user_id"
|
||||
add_foreign_key "wiki_versions", "wiki_pages"
|
||||
end
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||
# end
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||
end
|
||||
|
||||
post_url_sanitisation_rules = [
|
||||
{ priority: 10,
|
||||
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
|
||||
|
||||
@@ -3,8 +3,8 @@ namespace :nico do
|
||||
task export: :environment do
|
||||
require 'open3'
|
||||
|
||||
mysql_user = ENV.fetch('MYSQL_USER')
|
||||
mysql_pass = ENV.fetch('MYSQL_PASS')
|
||||
mysql_user = ENV.fetch('NIZIKA_NICO_MYSQL_USER')
|
||||
mysql_pass = ENV.fetch('NIZIKA_NICO_MYSQL_PASS')
|
||||
nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH')
|
||||
|
||||
videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map {
|
||||
@@ -14,7 +14,7 @@ namespace :nico do
|
||||
next if videos.empty?
|
||||
|
||||
_, stderr, status = Open3.capture3(
|
||||
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
|
||||
{ 'NIZIKA_NICO_MYSQL_USER' => mysql_user, 'NIZIKA_NICO_MYSQL_PASS' => mysql_pass },
|
||||
'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos,
|
||||
chdir: nizika_nico_path)
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace :help do
|
||||
desc 'ヘルプ移行'
|
||||
task migrate: :environment do
|
||||
help_path = ENV.fetch('BTRC_HUB_HELP_PATH')
|
||||
abort 'ヘルプ置場指定してね.' if help_path.blank?
|
||||
|
||||
# ファイルがすでに存在する場合は通さなぃ.
|
||||
abort 'ファイル消してね.' if Dir.glob("#{ help_path }/*").present?
|
||||
|
||||
# 現状物理削除はなぃはず
|
||||
pages =
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.where(tag_names: { tag_id: nil, language_code: 'ja' })
|
||||
.where.not("tag_names.name LIKE 'タググループ:%'")
|
||||
|
||||
puts "移行対象: #{pages.count} 件"
|
||||
abort '移行対象がありません.' if pages.empty?
|
||||
|
||||
pages.find_each do |page|
|
||||
puts page.body
|
||||
puts page.title
|
||||
|
||||
puts 'この identifier は何にする?'
|
||||
identifier = $stdin.gets.chomp
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }"
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }/editions"
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }/editions/ja"
|
||||
|
||||
last_file_name = ''
|
||||
WikiVersion.where(wiki_page_id: page.id).order(:version_no).each do |ver|
|
||||
last_file_name, last_file_name_no = last_file_name.split('.')
|
||||
|
||||
file_name = ver.created_at.strftime('%Y-%m-%d')
|
||||
if file_name == last_file_name
|
||||
file_name += ".#{ (last_file_name_no || 1).to_i + 1 }"
|
||||
end
|
||||
|
||||
body = "# #{ ver.title.sub(/^ヘルプ:/, '') }\n\n#{ ver.body }"
|
||||
File.write "#{ help_path }/#{ identifier }/editions/ja/#{ file_name }.mdx", body
|
||||
|
||||
last_file_name = file_name
|
||||
end
|
||||
|
||||
article_meta = { 'identifier' => identifier,
|
||||
'canonical_language' => 'ja',
|
||||
'languages' => { 'ja' => { 'current_edition' => last_file_name } } }
|
||||
File.write "#{ help_path }/#{ identifier }/article.yml", article_meta.to_yaml
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -54,11 +54,11 @@ namespace :nico do
|
||||
.find_each(&:destroy!)
|
||||
end
|
||||
|
||||
mysql_user = ENV['MYSQL_USER']
|
||||
mysql_pass = ENV['MYSQL_PASS']
|
||||
mysql_user = ENV['NIZIKA_NICO_MYSQL_USER']
|
||||
mysql_pass = ENV['NIZIKA_NICO_MYSQL_PASS']
|
||||
nizika_nico_path = ENV['NIZIKA_NICO_PATH']
|
||||
stdout, stderr, status = Open3.capture3(
|
||||
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
|
||||
{ 'NIZIKA_NICO_MYSQL_USER' => mysql_user, 'NIZIKA_NICO_MYSQL_PASS' => mysql_pass },
|
||||
'python3', "#{ nizika_nico_path }/get_videos.py")
|
||||
|
||||
unless status.success?
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
require 'rails_helper'
|
||||
require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages'
|
||||
|
||||
RSpec.describe 'Tag description migration identity' do
|
||||
it 'preserves every revision ID and repeated body in an independent sequence' do
|
||||
user = create_member_user!
|
||||
tag_name = create(:tag_name, name: 'description_migration')
|
||||
page = Wiki::Commit.create_content!(
|
||||
tag_name:, body: 'A', created_by_user: user)
|
||||
revisions = [page.current_revision]
|
||||
['A', 'B', 'B', 'A'].each do |body|
|
||||
revisions << Wiki::Commit.content!(
|
||||
page:, body:, created_user: user, base_revision_id: revisions.last.id)
|
||||
end
|
||||
tag = create(:tag, primary_tag_name: tag_name)
|
||||
legacy_state = [page.reload.attributes, revisions.map(&:attributes)]
|
||||
expect(page.wiki_versions.count).to eq(3)
|
||||
|
||||
migration = MigrateTagDescriptionsFromWikiPages.new
|
||||
migration.suppress_messages { migration.up }
|
||||
|
||||
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja')
|
||||
.order(:version_no)
|
||||
expect(versions.pluck(:id)).to eq(revisions.map(&:id))
|
||||
expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5])
|
||||
expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A'])
|
||||
expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5)
|
||||
expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at))
|
||||
expect(TagDescription.find(page.id)).to have_attributes(
|
||||
tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A')
|
||||
expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }])
|
||||
.to eq(legacy_state)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group persistence constraints' do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('制約確認') }
|
||||
let!(:group_id) do
|
||||
seed_group(
|
||||
name: '制約確認',
|
||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }])
|
||||
end
|
||||
|
||||
def placement attributes
|
||||
insert_group_row('tag_group_placements', {
|
||||
tag_group_id: group_id, position: 1,
|
||||
created_at: Time.current, updated_at: Time.current }.merge(attributes))
|
||||
end
|
||||
|
||||
def reject_statement
|
||||
before_state = group_state
|
||||
expect { yield }.to raise_error(ActiveRecord::StatementInvalid)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'allows membership without placement' do
|
||||
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
|
||||
expect(group_rows('tag_group_placements', tag_group_id: group_id)).to be_empty
|
||||
end
|
||||
|
||||
it 'allows repeated Tag placements with unique membership' do
|
||||
placement(tag_id: tag.id)
|
||||
placement(tag_id: tag.id, position: 2)
|
||||
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
|
||||
expect(group_rows('tag_group_placements', tag_group_id: group_id).size).to eq(2)
|
||||
end
|
||||
|
||||
it 'rejects duplicate Tag membership' do
|
||||
reject_statement do
|
||||
insert_group_row('tag_group_tags', {
|
||||
tag_group_id: group_id, tag_id: tag.id, created_at: Time.current })
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects duplicate child membership' do
|
||||
child_id = seed_group(name: '子')
|
||||
attributes = { tag_group_id: group_id, child_tag_group_id: child_id,
|
||||
created_at: Time.current }
|
||||
insert_group_row('tag_group_groups', attributes)
|
||||
reject_statement { insert_group_row('tag_group_groups', attributes) }
|
||||
end
|
||||
|
||||
it 'rejects direct self membership' do
|
||||
reject_statement do
|
||||
insert_group_row('tag_group_groups', {
|
||||
tag_group_id: group_id, child_tag_group_id: group_id, created_at: Time.current })
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a placement without a kind' do
|
||||
reject_statement { placement({ }) }
|
||||
end
|
||||
|
||||
it 'rejects simultaneous Section and Tag kinds' do
|
||||
reject_statement { placement(section_name: '区分', tag_id: tag.id) }
|
||||
end
|
||||
|
||||
it 'rejects a description attached to a Tag placement' do
|
||||
reject_statement { placement(tag_id: tag.id, description: '不正') }
|
||||
end
|
||||
|
||||
it 'allows an optional description on a Section' do
|
||||
placement(section_name: '区分', description: '説明')
|
||||
expect(group_rows('tag_group_placements', tag_group_id: group_id).sole)
|
||||
.to include('section_name' => '区分', 'description' => '説明')
|
||||
end
|
||||
|
||||
it 'rejects a Tag placement absent from membership' do
|
||||
other = group_tag('非メンバー')
|
||||
reject_statement { placement(tag_id: other.id) }
|
||||
end
|
||||
|
||||
it 'rejects a missing parent placement' do
|
||||
reject_statement { placement(section_name: '子', parent_position: 9) }
|
||||
end
|
||||
|
||||
it 'rejects a child ordered before its parent' do
|
||||
placement(section_name: '親', position: 2)
|
||||
reject_statement { placement(section_name: '子', position: 1, parent_position: 2) }
|
||||
end
|
||||
|
||||
it 'rejects duplicate group names' do
|
||||
reject_statement { seed_group(name: '制約確認') }
|
||||
end
|
||||
|
||||
it 'rejects a missing mandatory group name' do
|
||||
reject_statement { seed_group(name: nil) }
|
||||
end
|
||||
|
||||
it 'rejects a six-level snapshot' do
|
||||
nodes = []
|
||||
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
|
||||
reject_statement do
|
||||
insert_group_row('tag_group_versions', {
|
||||
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
|
||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
|
||||
placements: nodes.to_json, created_at: Time.current })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,17 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'TagName database invariants' do
|
||||
def insert_name!(**attributes)
|
||||
TagName.insert_all!([{ name: 'constraint_name', language_code: 'ja',
|
||||
script_code: 'Jpan', primary_flg: true,
|
||||
created_at: Time.current, updated_at: Time.current,
|
||||
**attributes }])
|
||||
def insert_name! **attributes
|
||||
defaults = {
|
||||
tag_id: create(:tag, primary_tag_name: nil).id,
|
||||
name: 'constraint_name',
|
||||
language_code: 'ja',
|
||||
script_code: 'Jpan',
|
||||
primary_flg: true,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current }
|
||||
|
||||
TagName.insert_all!([defaults.merge(attributes)])
|
||||
end
|
||||
|
||||
it 'defaults persisted names to human-confirmed provenance' do
|
||||
@@ -33,4 +39,9 @@ RSpec.describe 'TagName database invariants' do
|
||||
.to raise_error(ActiveRecord::RecordNotUnique)
|
||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'rejects a name without a tag' do
|
||||
expect { insert_name!(tag_id: nil) }
|
||||
.to raise_error(ActiveRecord::NotNullViolation)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Schema after Wiki removal' do
|
||||
it 'has no legacy Wiki tables or name indirection columns' do
|
||||
connection = ApplicationRecord.connection
|
||||
['wiki_pages', 'wiki_revisions', 'wiki_lines', 'wiki_revision_lines', 'wiki_versions']
|
||||
.each do |table|
|
||||
expect(connection.table_exists?(table)).to be(false)
|
||||
end
|
||||
expect(connection.column_exists?(:tags, :tag_name_id)).to be(false)
|
||||
expect(connection.column_exists?(:tag_names, :canonical_id)).to be(false)
|
||||
end
|
||||
|
||||
it 'requires TagName ownership while keeping the native resources and history' do
|
||||
connection = ApplicationRecord.connection
|
||||
expect(TagName.columns_hash.fetch('tag_id').null).to be(false)
|
||||
expect(connection.foreign_key_exists?(:tag_names, :tags)).to be(true)
|
||||
['tag_descriptions', 'tag_description_versions', 'tag_groups', 'tag_group_versions']
|
||||
.each do |table|
|
||||
expect(connection.table_exists?(table)).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,8 @@ FactoryBot.define do
|
||||
auto_generated { false }
|
||||
sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" }
|
||||
|
||||
association :tag, primary_tag_name: nil
|
||||
|
||||
trait :alias do
|
||||
primary_flg { false }
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ FactoryBot.define do
|
||||
primary_name { nil }
|
||||
primary_tag_name do
|
||||
attributes = primary_name.nil? ? { } : { name: primary_name }
|
||||
build(:tag_name, **attributes)
|
||||
build(:tag_name, tag: nil, **attributes)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,30 +12,19 @@ FactoryBot.define do
|
||||
post_count { 0 }
|
||||
|
||||
after(:build) do |tag, evaluator|
|
||||
next unless evaluator.primary_tag_name
|
||||
|
||||
evaluator.primary_tag_name.primary_flg = true
|
||||
evaluator.primary_tag_name.tag = tag
|
||||
tag.tag_names.target << evaluator.primary_tag_name
|
||||
end
|
||||
|
||||
before(:create) do |tag, evaluator|
|
||||
evaluator.primary_tag_name.save!
|
||||
# Compatibility write only; ownership is established through tag_id below.
|
||||
tag.tag_name_id = evaluator.primary_tag_name.id
|
||||
end
|
||||
|
||||
after(:create) do |tag, evaluator|
|
||||
next unless evaluator.primary_tag_name
|
||||
|
||||
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
|
||||
|
||||
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
|
||||
.uniq(&:language_code).each do |locale|
|
||||
next if tag.tag_names.exists?(language_code: locale.language_code,
|
||||
primary_flg: true)
|
||||
|
||||
create(:tag_name,
|
||||
tag:, language_code: locale.language_code,
|
||||
script_code: locale.script_code,
|
||||
name: TagName.generate_name(locale, tag, evaluator.primary_tag_name),
|
||||
primary_flg: true, auto_generated: true)
|
||||
end
|
||||
TagNameCompletion.for_tag!(tag)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
FactoryBot.define do
|
||||
factory :wiki_page do
|
||||
title { "TestPage" }
|
||||
association :created_user, factory: :user
|
||||
association :updated_user, factory: :user
|
||||
|
||||
body { ' ' }
|
||||
end
|
||||
end
|
||||
@@ -6,9 +6,8 @@ RSpec.describe Language, type: :model do
|
||||
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
|
||||
end
|
||||
|
||||
it 'accepts English when multilingual support is enabled' do
|
||||
pending '日本語以外の Language 有効化は別課題で実装する'
|
||||
expect { described_class.create!(code: 'en', name: 'English') }
|
||||
it 'accepts Ainu when multilingual support is enabled' do
|
||||
expect { described_class.create!(code: 'ain', name: 'ain') }
|
||||
.to change(described_class, :count).by(1)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Locale activation', type: :model do
|
||||
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
|
||||
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
|
||||
let!(:first) { create(:tag, primary_name: 'activation_first') }
|
||||
let!(:second) { create(:tag, primary_name: 'activation_second') }
|
||||
|
||||
def build_locale deprecated_at: nil
|
||||
Locale.new(code: 'ain-Kana', language_code: language.code,
|
||||
script_code: script.code, region_code: 392, variants: '',
|
||||
name: 'ain-Kana', deprecated_at:)
|
||||
end
|
||||
|
||||
def generated_names
|
||||
TagName.where(language_code: 'ain', primary_flg: true)
|
||||
end
|
||||
|
||||
def fail_second_generation!
|
||||
allow(TagName).to receive(:generate_name).and_call_original
|
||||
allow(TagName).to receive(:generate_name)
|
||||
.with(kind_of(Locale), second, second.tag_name('ja'))
|
||||
.and_raise('backfill failed')
|
||||
end
|
||||
|
||||
it 'rolls back a newly created locale and the preceding generated names' do
|
||||
locale = build_locale
|
||||
fail_second_generation!
|
||||
|
||||
expect { locale.save! }.to raise_error('backfill failed')
|
||||
|
||||
expect(Locale.exists?(code: 'ain-Kana')).to be(false)
|
||||
expect(generated_names).to be_empty
|
||||
expect(first.reload.name('ja')).to eq('activation_first')
|
||||
expect(second.reload.name('ja')).to eq('activation_second')
|
||||
end
|
||||
|
||||
it 'does not backfill while the locale is inactive' do
|
||||
build_locale(deprecated_at: Time.current).save!
|
||||
|
||||
expect(generated_names).to be_empty
|
||||
end
|
||||
|
||||
it 'backfills missing primary names when the locale becomes active' do
|
||||
locale = build_locale(deprecated_at: Time.current)
|
||||
locale.save!
|
||||
|
||||
locale.update!(deprecated_at: nil)
|
||||
|
||||
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
|
||||
expect(generated_names).to all(have_attributes(
|
||||
script_code: 'Kana', primary_flg: true, auto_generated: true))
|
||||
end
|
||||
|
||||
it 'rolls back activation and all generated names when backfill fails' do
|
||||
locale = build_locale(deprecated_at: Time.current)
|
||||
locale.save!
|
||||
previous_deprecated_at = locale.reload.deprecated_at
|
||||
fail_second_generation!
|
||||
|
||||
expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed')
|
||||
|
||||
expect(locale.reload.deprecated_at).to eq(previous_deprecated_at)
|
||||
expect(generated_names).to be_empty
|
||||
end
|
||||
|
||||
it 'preserves a human primary through deactivation and repeated activation' do
|
||||
locale = build_locale
|
||||
locale.save!
|
||||
primary = generated_names.find_by!(tag: first)
|
||||
primary.update!(name: 'human_confirmed', auto_generated: false)
|
||||
snapshot = generated_names.order(:id).map(&:attributes)
|
||||
|
||||
2.times do
|
||||
locale.update!(deprecated_at: Time.current)
|
||||
locale.update!(deprecated_at: nil)
|
||||
end
|
||||
|
||||
expect(generated_names.order(:id).map(&:attributes)).to eq(snapshot)
|
||||
expect(primary.reload).to have_attributes(name: 'human_confirmed', auto_generated: false)
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Locale, type: :model do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
def create_english_locale!
|
||||
prepare_english_reference!
|
||||
described_class.create!(code: 'en', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
variants: '', name: 'English')
|
||||
def create_ainu_locale!
|
||||
prepare_ainu_reference!
|
||||
described_class.create!(code: 'ain-Kana', language_code: 'ain',
|
||||
script_code: 'Kana', region_code: 392,
|
||||
variants: '', name: 'Ainu')
|
||||
end
|
||||
|
||||
it 'keeps the active Nipponese locale consistent with its identity tuple' do
|
||||
@@ -27,7 +27,6 @@ RSpec.describe Locale, type: :model do
|
||||
end
|
||||
|
||||
it 'generates a primary name for every existing tag using the name generator' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
first = create(:tag, primary_name: 'first_existing_tag')
|
||||
second = create(:tag, primary_name: 'second_existing_tag')
|
||||
allow(TagName).to receive(:generate_name).and_call_original
|
||||
@@ -35,63 +34,60 @@ RSpec.describe Locale, type: :model do
|
||||
.with(kind_of(described_class), first, primary_tag_name_for(first, 'ja'))
|
||||
.and_return('name_from_generator')
|
||||
|
||||
locale = create_english_locale!
|
||||
locale = create_ainu_locale!
|
||||
|
||||
expect(TagName).to have_received(:generate_name)
|
||||
.with(locale, first, primary_tag_name_for(first, 'ja'))
|
||||
expect(TagName).to have_received(:generate_name)
|
||||
.with(locale, second, primary_tag_name_for(second, 'ja'))
|
||||
expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true))
|
||||
expect(first.tag_names.find_by!(language_code: 'ain', primary_flg: true))
|
||||
.to have_attributes(name: 'name_from_generator', tag_id: first.id,
|
||||
language_code: 'en', script_code: 'Latn',
|
||||
language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, auto_generated: true)
|
||||
expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true))
|
||||
.to have_attributes(tag_id: second.id, language_code: 'en',
|
||||
script_code: 'Latn', primary_flg: true,
|
||||
expect(second.tag_names.find_by!(language_code: 'ain', primary_flg: true))
|
||||
.to have_attributes(tag_id: second.id, language_code: 'ain',
|
||||
script_code: 'Kana', primary_flg: true,
|
||||
auto_generated: true)
|
||||
expect(TagName.where(language_code: 'en', primary_flg: true,
|
||||
expect(TagName.where(language_code: 'ain', primary_flg: true,
|
||||
tag_id: [first.id, second.id]).count).to eq(2)
|
||||
end
|
||||
|
||||
it 'does not duplicate an existing primary name in the new language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
tag = create(:tag, primary_name: 'already_named')
|
||||
existing = create(:tag_name, name: 'existing_english', tag:,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
existing = create(:tag_name, name: 'existing_ainu', tag:,
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
create_english_locale!
|
||||
create_ainu_locale!
|
||||
|
||||
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true))
|
||||
expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true))
|
||||
.to contain_exactly(existing)
|
||||
expect(existing.reload).to have_attributes(
|
||||
tag_id: tag.id, language_code: 'en', script_code: 'Latn',
|
||||
tag_id: tag.id, language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, auto_generated: false)
|
||||
end
|
||||
|
||||
it 'creates a primary name when the language has only an alias' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
tag = create(:tag, primary_name: 'alias_only_tag')
|
||||
alias_name = create(:tag_name, :alias, name: 'alias_en',
|
||||
tag:,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
create_english_locale!
|
||||
create_ainu_locale!
|
||||
|
||||
expect(alias_name.reload).to have_attributes(
|
||||
tag_id: tag.id,
|
||||
language_code: 'en', script_code: 'Latn', primary_flg: false)
|
||||
generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en',
|
||||
script_code: 'Latn', primary_flg: true,
|
||||
language_code: 'ain', script_code: 'Kana', primary_flg: false)
|
||||
generated = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
|
||||
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'ain',
|
||||
script_code: 'Kana', primary_flg: true,
|
||||
auto_generated: true)
|
||||
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1)
|
||||
expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'rolls back the locale and all generated names when backfill fails' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
first = create(:tag, primary_name: 'atomic_first')
|
||||
second = create(:tag, primary_name: 'atomic_second')
|
||||
allow(TagName).to receive(:generate_name).and_call_original
|
||||
@@ -99,31 +95,27 @@ RSpec.describe Locale, type: :model do
|
||||
.with(kind_of(described_class), second, primary_tag_name_for(second, 'ja'))
|
||||
.and_raise('backfill failed')
|
||||
|
||||
expect { create_english_locale! }.to raise_error('backfill failed')
|
||||
expect { create_ainu_locale! }.to raise_error('backfill failed')
|
||||
|
||||
expect(described_class.exists?(code: 'en')).to be(false)
|
||||
expect(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty
|
||||
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
|
||||
end
|
||||
|
||||
it 'accepts a stable locale code outside the historical enum values' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
it 'rejects a private locale code outside the supported locale set' do
|
||||
prepare_ainu_reference!
|
||||
|
||||
locale = described_class.create!(
|
||||
code: 'en-x-rspec', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
variants: 'x-rspec', name: 'English')
|
||||
|
||||
expect(locale.reload).to have_attributes(
|
||||
code: 'en-x-rspec', language_code: 'en', script_code: 'Latn',
|
||||
region_code: 826, variants: 'x-rspec')
|
||||
expect {
|
||||
described_class.create!(
|
||||
code: 'ain-Kana-x-rspec', language_code: 'ain',
|
||||
script_code: 'Kana', region_code: 392,
|
||||
variants: 'x-rspec', name: 'Ainu')
|
||||
}.to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it 'registers the future British English locale tuple' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'registers the supported Ainu locale tuple' do
|
||||
|
||||
expect(locale).to have_attributes(code: 'en', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
expect(locale).to have_attributes(code: 'ain-Kana', language_code: 'ain',
|
||||
script_code: 'Kana', region_code: 392,
|
||||
variants: '')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Script, type: :model do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'selects locales by script code, not language code' do
|
||||
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
|
||||
@@ -25,11 +25,10 @@ RSpec.describe Script, type: :model do
|
||||
}
|
||||
end
|
||||
|
||||
it 'selects Latin locales when multilingual support is enabled' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'selects Kana locales when multilingual support is enabled' do
|
||||
locale
|
||||
|
||||
expect(described_class.find('Latn').locales).to contain_exactly(locale)
|
||||
expect(described_class.find('Kana').locales).to contain_exactly(locale)
|
||||
expect(described_class.find('Jpan').locales).not_to include(locale)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
|
||||
it 'allocates distinct increasing IDs across the two tables' do
|
||||
tag = create(:tag)
|
||||
external = create(:external_tag)
|
||||
next_tag = create(:tag)
|
||||
|
||||
expect(external.id).to be > tag.id
|
||||
expect(next_tag.id).to be > external.id
|
||||
end
|
||||
|
||||
it 'does not reuse physically deleted IDs in either table' do
|
||||
tag = create(:tag)
|
||||
external = create(:external_tag)
|
||||
TagName.where(tag_id: tag.id).delete_all
|
||||
tag.destroy!
|
||||
external.destroy!
|
||||
|
||||
expect(create(:tag).id).to be > external.id
|
||||
expect(create(:external_tag).id).to be > external.id
|
||||
end
|
||||
|
||||
it 'retains existing overlapping IDs without renumbering on update or insert' do
|
||||
tag = create(:tag)
|
||||
external = create(:external_tag)
|
||||
external.update_columns(id: tag.id)
|
||||
tag.update!(category: :meta)
|
||||
external.update!(name: 'updated_external')
|
||||
before_tag = tag.reload.attributes
|
||||
before_external = external.reload.attributes
|
||||
|
||||
expect(create(:tag).id).to be > tag.id
|
||||
expect(tag.reload.attributes).to eq(before_tag)
|
||||
expect(external.reload.attributes).to eq(before_external)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagGroup, type: :model do
|
||||
let(:actor) { create_member_user! }
|
||||
|
||||
def create_group name:, members: [], placements: []
|
||||
TagGroupOperation.create!(
|
||||
Locale.nipponese, name:, description: nil, members:, placements:, actor:)
|
||||
end
|
||||
|
||||
it 'retains version IDs and snapshots after destroying an empty group' do
|
||||
group = create_group(name: 'history owner')
|
||||
versions = group.versions.order(:version_no).map(&:attributes)
|
||||
|
||||
expect { group.destroy! }.to change(described_class, :count).by(-1)
|
||||
.and change(TagGroupVersion, :count).by(0)
|
||||
|
||||
expect(TagGroupVersion.where(tag_group_id: group.id).order(:version_no).map(&:attributes))
|
||||
.to eq(versions)
|
||||
expect(TagGroupRepr.historical(
|
||||
Locale.nipponese, TagGroupVersion.find(versions.first.fetch('id'))))
|
||||
.to include('id' => group.id, 'name' => 'history owner', 'members' => [])
|
||||
end
|
||||
|
||||
it 'destroys a group with nested placements and child membership, retaining history' do
|
||||
child = create_group(name: 'child group')
|
||||
|
||||
parent = create_group(
|
||||
name: 'parent group',
|
||||
members: [{ 'tag_group_id' => child.id }],
|
||||
placements: [{ 'section_name' => 'section', 'description' => nil,
|
||||
'children' => [{ 'tag_group_id' => child.id, 'children' => [] }] }])
|
||||
|
||||
versions = parent.versions.order(:version_no).map(&:attributes)
|
||||
|
||||
expect { parent.destroy! }
|
||||
.to change(TagGroup, :count).by(-1)
|
||||
.and change(TagGroupVersion, :count).by(0)
|
||||
|
||||
expect(TagGroupVersion.where(tag_group_id: parent.id)
|
||||
.order(:version_no).map(&:attributes)).to eq(versions)
|
||||
|
||||
expect(child.reload).to be_persisted
|
||||
expect(TagGroupPlacement.where(tag_group_id: parent.id)).to be_empty
|
||||
expect(TagGroupTag.where(tag_group_id: parent.id)).to be_empty
|
||||
expect(TagGroupGroup.where(tag_group_id: parent.id)).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -34,39 +34,97 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
||||
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
|
||||
end
|
||||
|
||||
[:primary, :alias].each do |source_kind|
|
||||
it "keeps the primary identity when a #{ source_kind } collides within its Tag" do
|
||||
tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar')
|
||||
primary = tag.tag_name('ja')
|
||||
alias_name = create(:tag_name, :alias, tag:,
|
||||
name: source_kind == :primary ? 'foobar' : 'temporary_alias')
|
||||
source = source_kind == :primary ? primary : alias_name
|
||||
source.update_columns(name: 'foo_bar')
|
||||
|
||||
expect { described_class.apply! }.to change(TagName, :count).by(-1)
|
||||
.and change(Tag, :count).by(0)
|
||||
|
||||
expect(primary.reload).to have_attributes(
|
||||
name: 'foobar', primary_flg: true, tag_id: tag.id)
|
||||
expect(TagName.exists?(alias_name.id)).to be(false)
|
||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
|
||||
.to contain_exactly(primary)
|
||||
expect(tag.tag_versions.order(:version_no).last.names.fetch('ja'))
|
||||
.to include('name' => 'foobar', 'aliases' => [])
|
||||
end
|
||||
end
|
||||
|
||||
it 'deduplicates colliding aliases without changing the primary name' do
|
||||
tag = create(:tag, primary_name: 'primary')
|
||||
primary = tag.tag_name('ja')
|
||||
kept = create(:tag_name, :alias, tag:, name: 'foobar')
|
||||
removed = create(:tag_name, :alias, tag:, name: 'temporary_alias')
|
||||
removed.update_columns(name: 'foo_bar')
|
||||
|
||||
described_class.apply!
|
||||
|
||||
expect(primary.reload).to have_attributes(name: 'primary', primary_flg: true)
|
||||
expect(kept.reload.tag_id).to eq(tag.id)
|
||||
expect(TagName.exists?(removed.id)).to be(false)
|
||||
expect(tag.tag_versions.order(:version_no).last.names.fetch('ja').fetch('aliases'))
|
||||
.to eq([{ 'name' => 'foobar', 'script_code' => 'Jpan' }])
|
||||
end
|
||||
|
||||
it 'rolls back a collision merge when the source owns a description' do
|
||||
user = create_member_user!
|
||||
target = create(:tag, primary_name: 'foobar')
|
||||
source = create(:tag, primary_name: 'temporary')
|
||||
TagDescriptionOperation.create!(
|
||||
Locale.nipponese, tag: source, body: 'retained', created_by_user: user)
|
||||
source.tag_name('ja').update_columns(name: 'foo_bar')
|
||||
post = create(:post)
|
||||
PostTag.create!(post:, tag: source)
|
||||
models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag]
|
||||
snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) }
|
||||
|
||||
expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid)
|
||||
|
||||
after_state = models.map do |model|
|
||||
model.order(*Array(model.primary_key)).map(&:attributes)
|
||||
end
|
||||
expect(after_state).to eq(snapshots)
|
||||
expect(post.reload.tags).to contain_exactly(source)
|
||||
expect(target.reload.name('ja')).to eq('foobar')
|
||||
end
|
||||
|
||||
context 'when only another language has the sanitised name' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'keeps both names when sanitisation produces a cross-language match' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag_name, name: 'foobar')
|
||||
english = create(:tag_name, name: 'temporary',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english.update_columns(name: 'foo_bar')
|
||||
ainu = create(:tag_name, name: 'temporary',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu.update_columns(name: 'foo_bar')
|
||||
|
||||
expect { described_class.apply! }.not_to change(TagName, :count)
|
||||
|
||||
expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en')
|
||||
expect(ainu.reload).to have_attributes(name: 'foobar', language_code: 'ain')
|
||||
expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
|
||||
end
|
||||
|
||||
it 'renames without merging tags or deleting either language identity' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'foobar')
|
||||
english_name = create(:tag_name, name: 'temporary',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name)
|
||||
english_name.update_columns(name: 'foo_bar')
|
||||
ainu_name = create(:tag_name, name: 'temporary',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||
ainu_name.update_columns(name: 'foo_bar')
|
||||
|
||||
described_class.apply!
|
||||
|
||||
expect(english_name.reload).to have_attributes(
|
||||
name: 'foobar', language_code: 'en', tag_id: english.id)
|
||||
expect(ainu_name.reload).to have_attributes(
|
||||
name: 'foobar', language_code: 'ain', tag_id: ainu.id)
|
||||
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
||||
name: 'foobar', language_code: 'ja', tag_id: nipponese.id)
|
||||
expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name)
|
||||
expect(primary_tag_name_for(ainu.reload, 'ain')).to eq(ainu_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -98,25 +156,6 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the source tag_name has a tag and the existing one has no tag' do
|
||||
let!(:existing) { create(:tag_name, name: 'foobar') }
|
||||
let!(:source_tag) { create(:tag, primary_name: 'tmp', category: :general) }
|
||||
let!(:source_tag_name_id) { primary_tag_name_for(source_tag, 'ja').id }
|
||||
|
||||
before do
|
||||
primary_tag_name_for(source_tag, 'ja').update_columns(
|
||||
name: 'foo_bar', updated_at: Time.current)
|
||||
end
|
||||
|
||||
it 'moves the tag to the existing tag_name' do
|
||||
described_class.apply!
|
||||
expect(existing.reload).to have_attributes(
|
||||
tag_id: source_tag.id, language_code: 'ja', primary_flg: true)
|
||||
expect(primary_tag_name_for(source_tag, 'ja')).to eq(existing)
|
||||
expect(primary_tag_name_for(source_tag.reload, 'ja')).to eq(existing)
|
||||
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the sanitised name is an alias of an existing tag' do
|
||||
let!(:existing_tag) { create(:tag) }
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagName, type: :model do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'allows the same name in different languages' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
create(:tag_name, name: 'shared_name')
|
||||
english = build(:tag_name, name: 'shared_name',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu = build(:tag_name, name: 'shared_name',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(english).to be_valid
|
||||
expect { english.save! }.to change(described_class, :count).by(1)
|
||||
expect(ainu).to be_valid
|
||||
expect { ainu.save! }.to change(described_class, :count).by(1)
|
||||
end
|
||||
|
||||
it 'rejects a duplicate name within the same language' do
|
||||
@@ -22,16 +21,23 @@ RSpec.describe TagName, type: :model do
|
||||
expect(duplicate.errors.of_kind?(:name, :taken)).to be(true)
|
||||
end
|
||||
|
||||
it 'creates Nipponese factory names without requiring a tag' do
|
||||
it 'creates Nipponese factory names with an owning tag' do
|
||||
name = create(:tag_name)
|
||||
|
||||
expect(name.reload).to have_attributes(
|
||||
language_code: 'ja', script_code: 'Jpan', primary_flg: true,
|
||||
auto_generated: false, tag_id: nil)
|
||||
auto_generated: false, tag_id: name.tag.id)
|
||||
end
|
||||
|
||||
it 'rejects an ownerless name at model and database boundaries' do
|
||||
name = build(:tag_name, tag: nil)
|
||||
expect(name).to be_invalid
|
||||
expect { name.save!(validate: false) }.to raise_error(ActiveRecord::NotNullViolation)
|
||||
end
|
||||
|
||||
it 'defaults ordinary persisted human names to confirmed provenance' do
|
||||
name = described_class.create!(name: 'ordinary_human_name', language_code: 'ja',
|
||||
name = described_class.create!(tag: create(:tag, primary_tag_name: nil),
|
||||
name: 'ordinary_human_name', language_code: 'ja',
|
||||
script_code: 'Jpan', primary_flg: true)
|
||||
|
||||
expect(name.reload.auto_generated).to be(false)
|
||||
@@ -46,7 +52,7 @@ RSpec.describe TagName, type: :model do
|
||||
expect(name.tag).to eq(tag)
|
||||
expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true))
|
||||
.to eq(name)
|
||||
expect(tag.reload.tag_name_id).to eq(name.id)
|
||||
expect(tag.reload.tag_name('ja')&.id).to eq(name.id)
|
||||
end
|
||||
|
||||
it 'creates a complete ordinary tag factory under the active Nipponese language' do
|
||||
@@ -60,11 +66,10 @@ RSpec.describe TagName, type: :model do
|
||||
end
|
||||
|
||||
it 'completes an ordinary tag factory under future active languages' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = create(:tag, primary_name: 'future_factory')
|
||||
|
||||
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
|
||||
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
|
||||
.to contain_exactly(have_attributes(auto_generated: true))
|
||||
end
|
||||
|
||||
@@ -96,62 +101,55 @@ RSpec.describe TagName, type: :model do
|
||||
end
|
||||
|
||||
describe '.canonicalise' do
|
||||
it 'leaves detached aliases unchanged' do
|
||||
create(:tag_name, :alias, name: 'detached_alias')
|
||||
|
||||
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
|
||||
.to eq(['detached_alias'])
|
||||
end
|
||||
|
||||
it 'does not resolve a detached alias through another detached primary' do
|
||||
create(:tag_name, :alias, name: 'detached_alias')
|
||||
create(:tag_name, name: 'unrelated_detached_primary')
|
||||
it 'resolves an alias to its owning primary name' do
|
||||
tag = create(:tag, primary_name: 'canonical')
|
||||
create(:tag_name, :alias, tag:, name: 'alias')
|
||||
|
||||
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
|
||||
.to eq(['detached_alias'])
|
||||
expect(described_class.canonicalise(Locale.nipponese, ['alias', 'unknown']))
|
||||
.to eq(['canonical', 'unknown'])
|
||||
end
|
||||
|
||||
it 'resolves the primary name of the alias language on the same tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = create(:tag, primary_name: '日本語正本')
|
||||
english_primary = primary_tag_name_for(tag, 'en')
|
||||
english_primary.update!(name: 'english_primary', auto_generated: false)
|
||||
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
representative_id = tag.tag_name_id
|
||||
ainu_primary = primary_tag_name_for(tag, 'ain')
|
||||
ainu_primary.update!(name: 'ainu_primary', auto_generated: false)
|
||||
ainu_alias = create(:tag_name, :alias, tag:, name: 'ainu_alias',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
representative_id = tag.tag_name('ja')&.id
|
||||
|
||||
primary = described_class.find_by!(tag_id: english_alias.tag_id,
|
||||
language_code: english_alias.language_code,
|
||||
primary = described_class.find_by!(tag_id: ainu_alias.tag_id,
|
||||
language_code: ainu_alias.language_code,
|
||||
primary_flg: true)
|
||||
|
||||
expect(primary).to eq(english_primary)
|
||||
expect(described_class.canonicalise(locale, [english_alias.name]))
|
||||
.to eq([english_primary.name])
|
||||
expect(tag.reload.tag_name_id).to eq(representative_id)
|
||||
expect(primary).to eq(ainu_primary)
|
||||
expect(described_class.canonicalise(locale, [ainu_alias.name]))
|
||||
.to eq([ainu_primary.name])
|
||||
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
||||
end
|
||||
|
||||
it 'resolves only aliases in the requested language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||
english_name = create(:tag_name, name: 'english_canonical',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name)
|
||||
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: nipponese)
|
||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: english,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: ainu,
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(english_alias.reload).to have_attributes(
|
||||
tag_id: english.id,
|
||||
primary_flg: false, language_code: 'en')
|
||||
expect(ainu_alias.reload).to have_attributes(
|
||||
tag_id: ainu.id,
|
||||
primary_flg: false, language_code: 'ain')
|
||||
expect(described_class.canonicalise(locale, ['shared_alias']))
|
||||
.to eq(['english_canonical'])
|
||||
.to eq(['ainu_canonical'])
|
||||
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
|
||||
.to eq(['nipponese_canonical'])
|
||||
end
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagRepr do
|
||||
let(:tag) { create(:tag) }
|
||||
|
||||
def description_for record
|
||||
TagDescription.create!(tag: record, language_code: 'ja', body: '', version_no: 1)
|
||||
end
|
||||
|
||||
it 'keeps has_wiki boolean for the selected language in base and inline' do
|
||||
description_for(tag)
|
||||
[:base, :inline].each do |method|
|
||||
expect(described_class.public_send(method, tag).as_json.fetch('has_wiki')).to be(true)
|
||||
expect(described_class.public_send(method, tag, language_code: 'en')
|
||||
.as_json.fetch('has_wiki')).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses the same language for a tag and its parents' do
|
||||
parent = create(:tag)
|
||||
description_for(tag)
|
||||
description_for(parent)
|
||||
TagImplication.create!(tag:, parent_tag: parent)
|
||||
['ja', 'en'].each do |language_code|
|
||||
row = described_class.base(tag, language_code:).as_json
|
||||
expected = language_code == 'ja'
|
||||
expect(row.fetch('has_wiki')).to be(expected)
|
||||
expect(row.fetch('parents').sole.fetch('has_wiki')).to be(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'preloads existence checks for many tags and their parents without Wiki reads' do
|
||||
parent = create(:tag)
|
||||
description_for(parent)
|
||||
records = Array.new(3) do
|
||||
create(:tag).tap { TagImplication.create!(tag: _1, parent_tag: parent) }
|
||||
end
|
||||
description_for(records.first)
|
||||
queries = []
|
||||
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
||||
rows = nil
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
rows = described_class.many(records).as_json
|
||||
end
|
||||
expect(rows.map { _1.fetch('has_wiki') }).to eq([true, false, false])
|
||||
expect(rows.map { _1.fetch('parents').sole.fetch('has_wiki') }).to eq([true] * 3)
|
||||
expect(queries.grep(/\bSELECT\b.*\btag_descriptions\b/im).size).to be <= 2
|
||||
expect(queries.grep(/\bwiki_(pages|revisions|versions)\b/i)).to be_empty
|
||||
end
|
||||
end
|
||||
+100
-118
@@ -1,6 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tag, type: :model do
|
||||
include WikiRecords
|
||||
describe '#has_description?' do
|
||||
let(:tag) { create(:tag) }
|
||||
|
||||
it 'checks the requested language and treats an empty description as present' do
|
||||
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
|
||||
expect(tag.has_description?(language_code: 'ja')).to be(true)
|
||||
expect(tag.has_description?(language_code: 'ain')).to be(false)
|
||||
end
|
||||
|
||||
it 'returns false without a description' do
|
||||
expect(tag.has_description?(language_code: 'ja')).to be(false)
|
||||
end
|
||||
|
||||
|
||||
it 'reuses preloaded descriptions without reading Wiki or querying again' do
|
||||
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
|
||||
loaded = Tag.includes(:tag_descriptions).find(tag.id)
|
||||
queries = []
|
||||
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
expect(loaded.has_description?(language_code: 'ja')).to be(true)
|
||||
expect(loaded.has_description?(language_code: 'ain')).to be(false)
|
||||
end
|
||||
expect(queries).to be_empty
|
||||
end
|
||||
|
||||
it 'keeps language selection generic without enabling another Language record' do
|
||||
tag.association(:tag_descriptions).target = [
|
||||
TagDescription.new(tag:, language_code: 'ain', body: '', version_no: 1)]
|
||||
tag.association(:tag_descriptions).loaded!
|
||||
expect(tag.has_description?(language_code: 'ain')).to be(true)
|
||||
expect(tag.has_description?(language_code: 'ja')).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'external tag separation' do
|
||||
['nico:reserved', 'NiCo:reserved'].each do |name|
|
||||
it "rejects the reserved prefix #{ name } for internal tags" do
|
||||
@@ -25,33 +61,32 @@ RSpec.describe Tag, type: :model do
|
||||
end
|
||||
|
||||
describe '.normalise_tags!' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'canonicalises aliases in the supplied language only' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||
english_name = create(:tag_name, name: 'english_canonical',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name)
|
||||
primary_tag_name_for(english, 'ja').update!(
|
||||
name: 'english_in_nipponese', auto_generated: false)
|
||||
english_locale = locale
|
||||
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||
primary_tag_name_for(ainu, 'ja').update!(
|
||||
name: 'ainu_in_nipponese', auto_generated: false)
|
||||
ainu_locale = locale
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: nipponese)
|
||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: english,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: ainu,
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(english_alias.reload).to have_attributes(
|
||||
tag_id: english.id,
|
||||
primary_flg: false, language_code: 'en')
|
||||
expect(ainu_alias.reload).to have_attributes(
|
||||
tag_id: ainu.id,
|
||||
primary_flg: false, language_code: 'ain')
|
||||
expect(described_class.normalise_tags!(
|
||||
english_locale, ['shared_alias'],
|
||||
with_tagme: false, with_no_deerjikist: false)).to eq([english])
|
||||
ainu_locale, ['shared_alias'],
|
||||
with_tagme: false, with_no_deerjikist: false)).to eq([ainu])
|
||||
expect(described_class.normalise_tags!(
|
||||
Locale.nipponese, ['shared_alias'],
|
||||
with_tagme: false, with_no_deerjikist: false)).to eq([nipponese])
|
||||
@@ -217,160 +252,120 @@ RSpec.describe Tag, type: :model do
|
||||
|
||||
describe '.find_or_create_by_tag_name!' do
|
||||
context 'with an explicit locale' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'creates the representative name with locale attributes and ownership' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = described_class.find_or_create_by_tag_name!(
|
||||
locale, 'english_name', category: :character)
|
||||
locale, 'ainu_name', category: :character)
|
||||
|
||||
expect(primary_tag_name_for(tag, 'en')).to have_attributes(
|
||||
language_code: 'en', script_code: 'Latn',
|
||||
expect(primary_tag_name_for(tag, 'ain')).to have_attributes(
|
||||
language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, tag_id: tag.id)
|
||||
expect(tag.category).to eq('character')
|
||||
end
|
||||
|
||||
it 'uses the English primary as the source for a generated Nipponese name' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'uses a Japanese fallback for an Ainu-origin tag without reverse transcription' do
|
||||
locale
|
||||
allow(TagName).to receive(:generate_name).and_return('generated_nipponese')
|
||||
allow(TagName).to receive(:generate_name).and_call_original
|
||||
|
||||
tag = described_class.find_or_create_by_tag_name!(
|
||||
locale, 'english_origin', category: :general)
|
||||
locale, 'ainu_origin', category: :general)
|
||||
|
||||
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
|
||||
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
|
||||
.to contain_exactly(have_attributes(
|
||||
name: 'english_origin', auto_generated: false))
|
||||
name: 'ainu_origin', auto_generated: false))
|
||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
|
||||
.to contain_exactly(have_attributes(
|
||||
name: 'generated_nipponese', auto_generated: true))
|
||||
source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true)
|
||||
name: "Tag_##{ tag.id }", auto_generated: true))
|
||||
expect(TagName).to have_received(:generate_name)
|
||||
.with(Locale.nipponese, tag, source_tag_name)
|
||||
.with(Locale.nipponese, tag, nil)
|
||||
end
|
||||
|
||||
it 'gives a new tag one primary name for every active language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = described_class.find_or_create_by_tag_name!(
|
||||
Locale.nipponese, 'new_multilingual_tag', category: :general)
|
||||
|
||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
||||
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
|
||||
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'creates one primary per language when multiple locales use English' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'creates one primary per language when multiple locales use Ainu' do
|
||||
locale
|
||||
described_class.find_or_create_by_tag_name!(
|
||||
locale, 'before_second_english_locale', category: :general)
|
||||
Locale.create!(code: 'en-x-rspec', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
variants: 'x-rspec', name: 'English')
|
||||
locale, 'before_second_ainu_locale', category: :general)
|
||||
|
||||
tag = described_class.find_or_create_by_tag_name!(
|
||||
locale, 'one_english_primary', category: :general)
|
||||
locale, 'one_ainu_primary', category: :general)
|
||||
TagNameCompletion.for_tag!(tag, locales: [locale, locale.dup, Locale.nipponese])
|
||||
|
||||
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
|
||||
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
|
||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'rolls back a new tag and requested name if name generation fails' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
original_tag_count = described_class.count
|
||||
allow(TagName).to receive(:generate_name).and_raise('generation failed')
|
||||
|
||||
expect {
|
||||
described_class.find_or_create_by_tag_name!(
|
||||
locale, 'atomic_english_name', category: :general)
|
||||
locale, 'atomic_ainu_name', category: :general)
|
||||
}.to raise_error('generation failed')
|
||||
|
||||
expect(described_class.count).to eq(original_tag_count)
|
||||
expect(TagName.where(language_code: 'en', name: 'atomic_english_name')).to be_empty
|
||||
expect(TagName.where(language_code: 'ain', name: 'atomic_ainu_name')).to be_empty
|
||||
expect(described_class.joins(:tag_names)
|
||||
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
|
||||
.where(tag_names: { name: 'atomic_ainu_name' })).to be_empty
|
||||
end
|
||||
|
||||
it 'keeps a detached requested name detached if generation fails' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
detached = create(:tag_name, :alias, name: 'atomic_detached',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
original_tag_count = described_class.count
|
||||
allow(TagName).to receive(:generate_name).and_raise('generation failed')
|
||||
|
||||
expect {
|
||||
described_class.find_or_create_by_tag_name!(
|
||||
locale, detached.name, category: :general)
|
||||
}.to raise_error('generation failed')
|
||||
|
||||
expect(described_class.count).to eq(original_tag_count)
|
||||
expect(detached.reload).to have_attributes(tag_id: nil, primary_flg: false)
|
||||
end
|
||||
|
||||
it 'promotes a detached alias into the requested primary name' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
detached = create(:tag_name, :alias, name: 'detached_english',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
|
||||
tag = described_class.find_or_create_by_tag_name!(
|
||||
locale, detached.name, category: :general)
|
||||
|
||||
expect(detached.reload).to have_attributes(
|
||||
tag_id: tag.id, primary_flg: true, canonical_id: nil,
|
||||
auto_generated: false)
|
||||
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
|
||||
.to contain_exactly(detached)
|
||||
end
|
||||
|
||||
it 'creates separate tag identities for the same name in different languages' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = described_class.find_or_create_by_tag_name!(
|
||||
Locale.nipponese, 'same_name', category: :general)
|
||||
english = described_class.find_or_create_by_tag_name!(
|
||||
primary_tag_name_for(nipponese, 'ain').update!(
|
||||
name: 'other_ainu_name', auto_generated: false)
|
||||
ainu = described_class.find_or_create_by_tag_name!(
|
||||
locale, 'same_name', category: :character)
|
||||
|
||||
expect(english).not_to eq(nipponese)
|
||||
expect(primary_tag_name_for(english, 'en')).to have_attributes(
|
||||
name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true)
|
||||
expect(ainu).not_to eq(nipponese)
|
||||
expect(primary_tag_name_for(ainu, 'ain')).to have_attributes(
|
||||
name: 'same_name', tag_id: ainu.id, language_code: 'ain', primary_flg: true)
|
||||
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
|
||||
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
|
||||
expect(described_class.find_or_create_by_tag_name!(
|
||||
locale, 'same_name', category: :general)).to eq(english)
|
||||
locale, 'same_name', category: :general)).to eq(ainu)
|
||||
expect(described_class.find_or_create_by_tag_name!(
|
||||
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
|
||||
end
|
||||
|
||||
it 'preserves the V1 representative when looking up another primary language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = create(:tag, primary_name: '日本語代表名')
|
||||
representative_id = tag.tag_name_id
|
||||
english = primary_tag_name_for(tag, 'en')
|
||||
english.update!(name: 'english_primary', auto_generated: false)
|
||||
representative_id = tag.tag_name('ja')&.id
|
||||
ainu = primary_tag_name_for(tag, 'ain')
|
||||
ainu.update!(name: 'ainu_primary', auto_generated: false)
|
||||
|
||||
found = described_class.find_or_create_by_tag_name!(
|
||||
locale, english.name, category: :general)
|
||||
locale, ainu.name, category: :general)
|
||||
|
||||
expect(found).to eq(tag)
|
||||
expect(tag.reload.tag_name_id).to eq(representative_id)
|
||||
expect(english.reload.tag_id).to eq(tag.id)
|
||||
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
||||
expect(ainu.reload.tag_id).to eq(tag.id)
|
||||
end
|
||||
|
||||
it 'normalises names using the supplied locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tags = described_class.normalise_tags!(
|
||||
locale, ['character:normalised_english'],
|
||||
locale, ['character:normalised_ainu'],
|
||||
with_tagme: false, with_no_deerjikist: false)
|
||||
|
||||
expect(tags.length).to eq(1)
|
||||
expect(primary_tag_name_for(tags.first, 'en')).to have_attributes(
|
||||
name: 'normalised_english', language_code: 'en', script_code: 'Latn',
|
||||
expect(primary_tag_name_for(tags.first, 'ain')).to have_attributes(
|
||||
name: 'normalised_ainu', language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, tag_id: tags.first.id)
|
||||
end
|
||||
end
|
||||
@@ -389,22 +384,10 @@ RSpec.describe Tag, type: :model do
|
||||
expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false)
|
||||
end
|
||||
|
||||
it 'promotes a detached Nipponese alias into a human-confirmed primary' do
|
||||
detached = create(:tag_name, :alias, name: 'detached_nipponese')
|
||||
|
||||
tag = described_class.find_or_create_by_tag_name!(
|
||||
Locale.nipponese, detached.name, category: :general)
|
||||
|
||||
expect(detached.reload).to have_attributes(
|
||||
tag_id: tag.id, primary_flg: true, canonical_id: nil,
|
||||
auto_generated: false)
|
||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
|
||||
.to contain_exactly(detached)
|
||||
end
|
||||
|
||||
it 'reuses the owning tag for an alias without changing its category' do
|
||||
tag = create(:tag, category: :character)
|
||||
representative_id = tag.tag_name_id
|
||||
representative_id = tag.tag_name('ja')&.id
|
||||
alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:)
|
||||
expect(alias_name.reload).to have_attributes(
|
||||
tag_id: tag.id,
|
||||
@@ -418,7 +401,7 @@ RSpec.describe Tag, type: :model do
|
||||
|
||||
expect(found).to eq(tag)
|
||||
expect(found.category).to eq('character')
|
||||
expect(tag.reload.tag_name_id).to eq(representative_id)
|
||||
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
||||
end
|
||||
|
||||
it 'reuses the owning tag through another alias' do
|
||||
@@ -451,26 +434,25 @@ RSpec.describe Tag, type: :model do
|
||||
end
|
||||
|
||||
context 'with names in multiple languages' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'keeps the target primary and moves source names as aliases' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
english_primary = primary_tag_name_for(target_tag, 'en')
|
||||
source_primary = primary_tag_name_for(source_tag, 'en')
|
||||
ainu_primary = primary_tag_name_for(target_tag, 'ain')
|
||||
source_primary = primary_tag_name_for(source_tag, 'ain')
|
||||
source_alias = create(:tag_name, :alias, tag: source_tag,
|
||||
name: 'source_english_alias', language_code: 'en', script_code: 'Latn')
|
||||
name: 'source_ainu_alias', language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect([english_primary, source_primary]).to all(have_attributes(
|
||||
expect([ainu_primary, source_primary]).to all(have_attributes(
|
||||
primary_flg: true, auto_generated: true))
|
||||
|
||||
described_class.merge_tags!(target_tag, [source_tag])
|
||||
|
||||
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary)
|
||||
expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true))
|
||||
.to contain_exactly(english_primary)
|
||||
expect(primary_tag_name_for(target_tag.reload, 'ain')).to eq(ainu_primary)
|
||||
expect(TagName.where(tag: target_tag, language_code: 'ain', primary_flg: true))
|
||||
.to contain_exactly(ainu_primary)
|
||||
expect([source_primary.reload, source_alias.reload]).to all(have_attributes(
|
||||
tag_id: target_tag.id, language_code: 'en', primary_flg: false))
|
||||
tag_id: target_tag.id, language_code: 'ain', primary_flg: false))
|
||||
expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true))
|
||||
.to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
|
||||
expect(source_tag_name.reload).to have_attributes(
|
||||
@@ -642,7 +624,7 @@ RSpec.describe Tag, type: :model do
|
||||
let!(:wiki_page) do
|
||||
admin = create_admin_user!
|
||||
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: source_tag_name,
|
||||
body: 'source wiki body',
|
||||
created_by_user: admin,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe User, type: :model do
|
||||
it 'nullifies description authorship without deleting native content or history' do
|
||||
author = create_member_user!
|
||||
editor = create_member_user!
|
||||
tag = create(:tag)
|
||||
description = TagDescriptionOperation.create!(
|
||||
Locale.nipponese, tag:, body: 'initial', created_by_user: author)
|
||||
TagDescriptionOperation.update!(
|
||||
Locale.nipponese, tag:, body: 'edited', updated_by_user: editor)
|
||||
versions = TagDescriptionVersion.where(tag_id: tag.id).order(:version_no)
|
||||
snapshots = versions.map(&:attributes)
|
||||
|
||||
expect(author.created_tag_descriptions).to contain_exactly(description)
|
||||
expect(editor.updated_tag_descriptions).to contain_exactly(description)
|
||||
expect { author.destroy! }.to change(TagDescription, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
expect(description.reload).to have_attributes(
|
||||
body: 'edited', created_by_user_id: nil, updated_by_user_id: editor.id)
|
||||
expect { editor.destroy! }.not_to change(TagDescription, :count)
|
||||
expect(description.reload.updated_by_user_id).to be_nil
|
||||
expect(versions.reload.map(&:attributes)).to eq(snapshots)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'API locale resolution', type: :request do
|
||||
let!(:tag) { create(:tag, primary_name: '日本語正本') }
|
||||
|
||||
it 'defaults an omitted locale to Japanese' do
|
||||
get "/tags/#{ tag.id }"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('name')).to eq('日本語正本')
|
||||
end
|
||||
|
||||
it 'accepts an explicitly requested active Japanese locale' do
|
||||
get "/tags/#{ tag.id }", params: { locale: 'ja' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('name')).to eq('日本語正本')
|
||||
end
|
||||
|
||||
['', 'en', 'ain', 'Jpan', 'JA', 'unknown', ['ja']].each do |code|
|
||||
it "rejects invalid locale #{ code.inspect } instead of defaulting to Japanese" do
|
||||
get "/tags/#{ tag.id }", params: { locale: code }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.body).not_to include('日本語正本')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a supported locale that has not been registered' do
|
||||
get "/tags/#{ tag.id }", params: { locale: 'ain-Kana' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'rejects an inactive locale' do
|
||||
Locale.nipponese.update!(deprecated_at: Time.current)
|
||||
|
||||
get "/tags/#{ tag.id }", params: { locale: 'ja' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'does not write posts or tag names under an invalid locale' do
|
||||
sign_in_as(create_member_user!)
|
||||
|
||||
expect do
|
||||
post '/posts', params: {
|
||||
locale: 'en', title: 'invalid locale', url: 'https://example.com/invalid-locale',
|
||||
tags: 'unwritten_tag' }
|
||||
end.to change(Post, :count).by(0).and change(TagName, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
@@ -54,13 +54,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
['en', nil].each do |requested_locale|
|
||||
['ain-Kana', nil].each do |requested_locale|
|
||||
context "with locale #{ requested_locale.inspect }" do
|
||||
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
|
||||
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
|
||||
|
||||
it 'creates post tags in the requested language or Nipponese fallback' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
post '/posts', params: locale_params.merge(
|
||||
title: 'Locale post', url: 'https://example.com/locale-post',
|
||||
@@ -72,7 +71,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'updates post tags in the requested language or Nipponese fallback' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
record = create(:post)
|
||||
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
||||
@@ -87,7 +85,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'passes the resolved locale to bulk creation' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
# Worker propagation is exercised in post_bulk_creator_spec.
|
||||
expect_any_instance_of(PostBulkCreator).to receive(:run)
|
||||
@@ -102,7 +99,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'creates materials with tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
post '/materials', params: locale_params.merge(
|
||||
tag: 'locale_material', url: 'https://example.com/material')
|
||||
@@ -113,7 +109,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'updates materials with tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
tag = create(:tag, category: :material)
|
||||
material = Material.create!(tag:, url: 'https://example.com/material')
|
||||
@@ -127,7 +122,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'links Nico external tags to internal tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
external = create(:external_tag)
|
||||
NicoTagVersionRecorder.record!(
|
||||
@@ -141,7 +135,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'creates parent tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
tag = create(:tag)
|
||||
|
||||
@@ -157,10 +150,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
context 'with existing names in both languages' do
|
||||
let(:english_name) do
|
||||
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn')
|
||||
let(:ainu_name) do
|
||||
create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana')
|
||||
end
|
||||
let(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) }
|
||||
let(:ainu_tag) { create(:tag, primary_tag_name: ainu_name, category: :material) }
|
||||
let(:nipponese_tag) do
|
||||
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag|
|
||||
# Isolate lookup from the separately tested uniqueness validation.
|
||||
@@ -168,173 +161,163 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'plans and creates the post using the English identity and category' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'plans and creates the post using the Ainu identity and category' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
post '/posts', params: {
|
||||
locale: 'en', title: 'English identity',
|
||||
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' }
|
||||
locale: 'ain-Kana', title: 'Ainu identity',
|
||||
url: 'https://example.com/ainu-identity', tags: 'shared_name', parent_post_ids: '' }
|
||||
|
||||
expect(response).to have_http_status(:created), response.body
|
||||
record = Post.find(json.fetch('id'))
|
||||
expect(record.tags).to include(english_tag)
|
||||
expect(record.tags).to include(ainu_tag)
|
||||
expect(record.tags).not_to include(nipponese_tag)
|
||||
expect(english_tag.reload.category).to eq('material')
|
||||
expect(ainu_tag.reload.category).to eq('material')
|
||||
expect(nipponese_tag.reload.category).to eq('general')
|
||||
end
|
||||
|
||||
it 'updates the post using the English identity' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'updates the post using the Ainu identity' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
record = create(:post)
|
||||
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
||||
|
||||
put "/posts/#{ record.id }", params: {
|
||||
locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '',
|
||||
locale: 'ain-Kana', title: record.title, tags: 'shared_name', parent_post_ids: '',
|
||||
base_version_no: record.reload.version_no }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(record.reload.tags).to include(english_tag)
|
||||
expect(record.reload.tags).to include(ainu_tag)
|
||||
expect(record.tags).not_to include(nipponese_tag)
|
||||
end
|
||||
|
||||
it 'resolves the English material tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu material tag' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
post '/materials', params: {
|
||||
locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' }
|
||||
locale: 'ain-Kana', tag: 'shared_name', url: 'https://example.com/ainu-material' }
|
||||
|
||||
expect(response).to have_http_status(:created), response.body
|
||||
expect(Material.find(json.fetch('id')).tag).to eq(english_tag)
|
||||
expect(Material.find(json.fetch('id')).tag).to eq(ainu_tag)
|
||||
expect(Material.where(tag: nipponese_tag)).to be_empty
|
||||
end
|
||||
|
||||
it 'resolves the English parent tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu parent tag' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
child = create(:tag)
|
||||
|
||||
put "/tags/#{ child.id }", params: {
|
||||
locale: 'en', name: primary_tag_name_for(child, 'ja').name,
|
||||
locale: 'ain-Kana', name: primary_tag_name_for(child, 'ja').name,
|
||||
category: child.category, deprecated: false,
|
||||
aliases: '', parent_tags: 'shared_name' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id])
|
||||
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([ainu_tag.id])
|
||||
end
|
||||
|
||||
it 'resolves the English internal tag for a Nico link' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu internal tag for a Nico link' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
external = create(:external_tag)
|
||||
NicoTagVersionRecorder.record!(
|
||||
external_tag: external, event_type: :create, created_by_user: member)
|
||||
|
||||
put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' }
|
||||
put "/tags/nico/#{ external.id }", params: { locale: 'ain-Kana', tags: 'shared_name' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(external.reload.linked_tags).to contain_exactly(english_tag)
|
||||
expect(external.reload.linked_tags).to contain_exactly(ainu_tag)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when renaming or updating aliases in English' do
|
||||
let(:english_name) do
|
||||
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn')
|
||||
context 'when renaming or updating aliases in Ainu' do
|
||||
let(:ainu_name) do
|
||||
create(:tag_name, name: 'ainu_original', language_code: 'ain', script_code: 'Kana')
|
||||
end
|
||||
let(:tag) { create(:tag, primary_tag_name: english_name) }
|
||||
let(:tag) { create(:tag, primary_tag_name: ainu_name) }
|
||||
|
||||
it 'marks a human-renamed generated primary as no longer auto-generated' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
generated_tag = create(:tag, primary_name: 'rename_generated_source')
|
||||
generated_name = primary_tag_name_for(generated_tag, 'en')
|
||||
generated_name = primary_tag_name_for(generated_tag, 'ain')
|
||||
expect(generated_name.auto_generated).to be(true)
|
||||
|
||||
patch "/tags/#{ generated_tag.id }", params: {
|
||||
locale: 'en', name: 'human_english_name' }
|
||||
locale: 'ain-Kana', name: 'human_ainu_name' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(generated_name.reload).to have_attributes(
|
||||
name: 'human_english_name', auto_generated: false)
|
||||
name: 'human_ainu_name', auto_generated: false)
|
||||
end
|
||||
|
||||
it 'does not steal another tag primary for an alias' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
other_tag = create(:tag, primary_name: 'other_nipponese')
|
||||
other_primary = primary_tag_name_for(other_tag, 'en')
|
||||
other_primary.update!(name: 'other_english_primary', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, name: 'existing_english_alias',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
primary_name_before = primary_tag_name_for(tag, 'en').name
|
||||
other_primary = primary_tag_name_for(other_tag, 'ain')
|
||||
other_primary.update!(name: 'other_ainu_primary', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, name: 'existing_ainu_alias',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
primary_name_before = primary_tag_name_for(tag, 'ain').name
|
||||
aliases_before =
|
||||
tag.tag_names.where(language_code: 'en', primary_flg: false)
|
||||
tag.tag_names.where(language_code: 'ain', primary_flg: false)
|
||||
.order(:name).pluck(:name)
|
||||
category_before = tag.category
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: 'renamed_before_alias_collision',
|
||||
locale: 'ain-Kana', name: 'renamed_before_alias_collision',
|
||||
category: 'character', deprecated: false,
|
||||
aliases: 'other_english_primary', parent_tags: '' }
|
||||
aliases: 'other_ainu_primary', parent_tags: '' }
|
||||
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(other_primary.reload).to have_attributes(
|
||||
tag_id: other_tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(TagName.where(tag: other_tag, language_code: 'en', primary_flg: true))
|
||||
tag_id: other_tag.id, language_code: 'ain', primary_flg: true)
|
||||
expect(TagName.where(tag: other_tag, language_code: 'ain', primary_flg: true))
|
||||
.to contain_exactly(other_primary)
|
||||
expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before)
|
||||
expect(primary_tag_name_for(tag.reload, 'ain').name).to eq(primary_name_before)
|
||||
expect(tag.category).to eq(category_before)
|
||||
aliases_after =
|
||||
tag.tag_names.where(language_code: 'en', primary_flg: false)
|
||||
tag.tag_names.where(language_code: 'ain', primary_flg: false)
|
||||
.order(:name).pluck(:name)
|
||||
expect(aliases_after).to eq(aliases_before)
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows a rename to a name already used in Nipponese' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'rename_target')
|
||||
|
||||
patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' }
|
||||
patch "/tags/#{ tag.id }", params: { locale: 'ain-Kana', name: 'rename_target' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'en', tag_id: tag.id)
|
||||
expect(primary_tag_name_for(tag.reload, 'ain')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'ain', tag_id: tag.id)
|
||||
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
|
||||
end
|
||||
|
||||
it 'creates aliases in the requested language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: english_name.name,
|
||||
locale: 'ain-Kana', name: ainu_name.name,
|
||||
category: tag.category, deprecated: false,
|
||||
aliases: 'english_alias', parent_tags: '' }
|
||||
aliases: 'ainu_alias', parent_tags: '' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(TagName.find_by!(language_code: 'en', name: 'english_alias'))
|
||||
expect(TagName.find_by!(language_code: 'ain', name: 'ainu_alias'))
|
||||
.to have_attributes(
|
||||
script_code: 'Latn',
|
||||
tag_id: tag.id, primary_flg: false, language_code: 'en')
|
||||
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn')
|
||||
script_code: 'Kana',
|
||||
tag_id: tag.id, primary_flg: false, language_code: 'ain')
|
||||
primary = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
|
||||
expect(primary).to have_attributes(name: 'ainu_original', script_code: 'Kana')
|
||||
end
|
||||
|
||||
it 'does not take an alias from another language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'nipponese_owner')
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
@@ -344,7 +327,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
primary_flg: false, language_code: 'ja')
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: english_name.name,
|
||||
locale: 'ain-Kana', name: ainu_name.name,
|
||||
category: tag.category, deprecated: false,
|
||||
aliases: 'shared_alias', parent_tags: '' }
|
||||
|
||||
@@ -352,12 +335,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(TagName.find_by!(language_code: 'en', name: 'shared_alias'))
|
||||
expect(TagName.find_by!(language_code: 'ain', name: 'shared_alias'))
|
||||
.to have_attributes(
|
||||
tag_id: tag.id, script_code: 'Latn',
|
||||
primary_flg: false, language_code: 'en')
|
||||
tag_id: tag.id, script_code: 'Kana',
|
||||
primary_flg: false, language_code: 'ain')
|
||||
expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id))
|
||||
.to contain_exactly(['ja', nipponese.id], ['en', tag.id])
|
||||
.to contain_exactly(['ja', nipponese.id], ['ain', tag.id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -178,6 +178,7 @@ RSpec.describe 'Materials API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /materials/:id' do
|
||||
include WikiRecords
|
||||
let!(:tag) do
|
||||
create(:tag, primary_name: 'material_show', category: :material)
|
||||
end
|
||||
@@ -205,6 +206,54 @@ RSpec.describe 'Materials API', type: :request do
|
||||
get '/materials/999999999'
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
def get_material_without_legacy_reads
|
||||
queries = []
|
||||
subscriber = lambda do |*args|
|
||||
sql = args.last.fetch(:sql)
|
||||
if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i)
|
||||
queries << sql
|
||||
end
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
get "/materials/#{ material.id }"
|
||||
end
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(queries).to be_empty
|
||||
end
|
||||
|
||||
it 'reads the description even when the stored version has different content' do
|
||||
page = seed_description(tag_name: tag.tag_name('ja'), body: 'legacy body',
|
||||
created_by_user: member_user)
|
||||
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
.update!(body: 'description body')
|
||||
get_material_without_legacy_reads
|
||||
expect(json.fetch('wiki_page_body')).to eq('description body')
|
||||
expect(current_description_version(page).body).to eq('legacy body')
|
||||
end
|
||||
|
||||
it 'uses tag_id rather than description Id. without a Wiki page' do
|
||||
description = TagDescription.create!(tag:, language_code: 'ja',
|
||||
body: 'independent body', version_no: 1)
|
||||
get_material_without_legacy_reads
|
||||
expect(json.fetch('wiki_page_body')).to eq('independent body')
|
||||
expect(json.fetch('tag').fetch('has_wiki')).to be(true)
|
||||
end
|
||||
|
||||
it 'returns nil when only description history remains' do
|
||||
seed_description(tag_name: tag.tag_name('ja'), body: 'legacy only',
|
||||
created_by_user: member_user)
|
||||
TagDescription.where(tag_id: tag.id).delete_all
|
||||
get_material_without_legacy_reads
|
||||
expect(json.fetch('wiki_page_body')).to be_nil
|
||||
expect(json.fetch('tag').fetch('has_wiki')).to be(false)
|
||||
end
|
||||
|
||||
it 'returns nil for an untagged material' do
|
||||
material.update!(tag: nil)
|
||||
get_material_without_legacy_reads
|
||||
expect(json.fetch('wiki_page_body')).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /materials' do
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag_name) { create(:tag_name, name: 'description_contract') }
|
||||
let!(:page) do
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name:, body: 'old-revision', created_by_user: user)
|
||||
end
|
||||
let!(:revisions) do
|
||||
first = page.current_revision
|
||||
second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user)
|
||||
third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user)
|
||||
[first, second, third]
|
||||
end
|
||||
let!(:tag) { create(:tag, primary_tag_name: tag_name) }
|
||||
let!(:tag) { tag_name.tag }
|
||||
let!(:versions) do
|
||||
revisions.each_with_index.map do |revision, index|
|
||||
['A', 'A', 'new-version'].each_with_index.map do |body, index|
|
||||
TagDescriptionVersion.create!(
|
||||
id: revision.id, tag_id: tag.id, language_code: 'ja',
|
||||
version_no: index + 1, event_type: index.zero? ? 'create' : 'update',
|
||||
body: index == 2 ? 'new-version' : 'A',
|
||||
created_by_user_id: user.id, created_at: revision.created_at)
|
||||
tag_id: tag.id, language_code: 'ja', version_no: index + 1,
|
||||
event_type: index.zero? ? 'create' : 'update', body:,
|
||||
created_by_user_id: user.id, created_at: Time.current)
|
||||
end
|
||||
end
|
||||
let!(:description) do
|
||||
TagDescription.create!(
|
||||
id: page.id, tag:, language_code: 'ja', version_no: 3, body: 'new',
|
||||
tag:, language_code: 'ja', version_no: 3, body: 'new',
|
||||
created_by_user_id: user.id, updated_by_user_id: user.id)
|
||||
end
|
||||
|
||||
before { page.update_columns(body: 'old') }
|
||||
|
||||
# Observe real SELECTs, including cached queries, only during the request.
|
||||
def without_legacy_reads
|
||||
queries = []
|
||||
@@ -69,7 +57,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
it 'does not fall back to a revision absent from new history' do
|
||||
versions.first.destroy!
|
||||
without_legacy_reads do
|
||||
get "/wiki/#{ description.id }", params: { version: revisions.first.id }
|
||||
get "/wiki/#{ description.id }", params: { version: versions.first.id }
|
||||
end
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
@@ -131,16 +119,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
'pred' => versions.first.id, 'succ' => versions.last.id)
|
||||
end
|
||||
|
||||
it 'ignores changes to same-title legacy content and revision metadata' do
|
||||
get "/wiki/title/#{ tag_name.name }"
|
||||
original = json
|
||||
page.update_columns(body: 'changed legacy body')
|
||||
revisions.last.update_columns(message: 'legacy message', created_at: 1.day.ago)
|
||||
revisions.last.wiki_lines.update_all(body: 'changed legacy revision')
|
||||
get "/wiki/title/#{ tag_name.name }"
|
||||
expect(json).to eq(original)
|
||||
expect(json.fetch('body')).to eq('new')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'exists' do
|
||||
@@ -154,28 +132,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
context 'without a legacy page' do
|
||||
before do
|
||||
WikiVersion.where(wiki_page_id: page.id).delete_all
|
||||
page.destroy!
|
||||
end
|
||||
|
||||
it 'recognises the description ID alone' do
|
||||
get "/wiki/#{ description.id }/exists"
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'recognises the TagName alone' do
|
||||
get "/wiki/title/#{ tag_name.name }/exists"
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'shows the description by title' do
|
||||
get "/wiki/title/#{ tag_name.name }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => 'new', 'revision_id' => versions.last.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'diff' do
|
||||
@@ -228,39 +184,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
'title' => tag_name.name)
|
||||
end
|
||||
|
||||
it 'ignores legacy-only revisions and metadata changes' do
|
||||
get '/wiki/changes', params: { id: description.id }
|
||||
original = json
|
||||
revisions.first.update_columns(message: 'legacy changed', created_at: 1.day.ago)
|
||||
WikiRevision.create!(
|
||||
wiki_page: page, created_user: user, kind: :content,
|
||||
tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0)
|
||||
get '/wiki/changes', params: { id: description.id }
|
||||
expect(json).to eq(original)
|
||||
expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'mixed changes' do
|
||||
it 'includes unmigrated history and ignores migrated legacy-only revisions' do
|
||||
legacy_page = Wiki::Commit.create_content!(
|
||||
tag_name: create(:tag_name, name: 'Help_mixed'),
|
||||
body: 'help', created_by_user: user)
|
||||
legacy_revision = legacy_page.current_revision
|
||||
extra = WikiRevision.create!(
|
||||
wiki_page: page, created_user: user, kind: :content,
|
||||
tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0)
|
||||
|
||||
get '/wiki/changes'
|
||||
expect(response).to have_http_status(:ok)
|
||||
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
|
||||
expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
|
||||
expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id)
|
||||
expect(json).to include(include(
|
||||
'revision_id' => legacy_revision.id,
|
||||
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'index' do
|
||||
[nil, 'description_contract'].each do |title|
|
||||
|
||||
@@ -1,273 +1,93 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
RSpec.describe 'Tag description Wiki facade writes', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag_name) { create(:tag_name, name: 'description_write') }
|
||||
let(:tag) { create(:tag, primary_tag_name: tag_name) }
|
||||
let!(:tag) { create(:tag, primary_name: 'description_write') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
|
||||
def stored_state
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
|
||||
TagName, TagVersion].map do |model|
|
||||
[Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model|
|
||||
model.order(:id).map(&:attributes)
|
||||
end
|
||||
end
|
||||
|
||||
# Fail after a real INSERT/UPDATE, exercising rollback rather than a mock write.
|
||||
def fail_save model
|
||||
failure = Class.new(StandardError)
|
||||
callback = ->(_record) { raise failure, 'injected persistence failure' }
|
||||
model.set_callback(:save, :after, callback)
|
||||
expect { yield }.to raise_error(failure)
|
||||
ensure
|
||||
model.skip_callback(:save, :after, callback)
|
||||
it 'creates a native description and exposes its version ID as revision ID' do
|
||||
expect do
|
||||
post '/wiki', params: { title: tag.name('ja'), body: 'initial' }, headers: headers
|
||||
end.to change(TagDescription, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
description = TagDescription.find(json.fetch('id'))
|
||||
expect(description).to have_attributes(tag_id: tag.id, body: 'initial', version_no: 1)
|
||||
get "/wiki/#{ description.id }"
|
||||
expect(json.fetch('revision_id')).to eq(current_description_version(description).id)
|
||||
end
|
||||
|
||||
describe 'POST /wiki' do
|
||||
before { tag }
|
||||
context 'with an existing description' do
|
||||
let!(:description) do
|
||||
TagDescriptionOperation.create!(
|
||||
Locale.nipponese, tag:, body: 'before', created_by_user: user)
|
||||
end
|
||||
let!(:first_version) { current_description_version(description) }
|
||||
|
||||
def create_description
|
||||
post '/wiki', params: { title: tag_name.name, body: 'initial', message: 'create' },
|
||||
headers: headers
|
||||
def update_description body: 'after', title: tag.name('ja'), base: first_version.id
|
||||
put "/wiki/#{ description.id }", params: { title:, body:, base_revision_id: base },
|
||||
headers: headers
|
||||
end
|
||||
|
||||
it 'creates both current records and histories with a shared revision ID' do
|
||||
expect { create_description }
|
||||
.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescription, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1)
|
||||
expect(version).to have_attributes(
|
||||
id: page.current_revision.id, body: 'initial', version_no: 1,
|
||||
event_type: 'create', created_by_user_id: user.id)
|
||||
expect(page.body).to eq(description.body)
|
||||
expect(page.current_revision.body).to eq(version.body)
|
||||
expect(page.wiki_versions.last.body).to eq(version.body)
|
||||
it 'records a body change without changing resource identity or old history' do
|
||||
snapshot = first_version.attributes
|
||||
expect { update_description }.to change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
|
||||
expect(first_version.reload.attributes).to eq(snapshot)
|
||||
expect(current_description_version(description)).to have_attributes(
|
||||
body: 'after', event_type: 'update', created_by_user_id: user.id)
|
||||
end
|
||||
|
||||
['missing_description', 'orphan_description'].each do |title|
|
||||
it "rejects the bare title #{ title } without writing any resource" do
|
||||
create(:tag_name, name: title) if title == 'orphan_description'
|
||||
['before', "before\r\n\r\n"].each do |body|
|
||||
it "does not write for normalised-equivalent body #{ body.inspect }" do
|
||||
before_state = stored_state
|
||||
|
||||
post '/wiki', params: { title:, body: 'initial' }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json).to include('type' => 'validation_error')
|
||||
update_description(body:)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a namespaced legacy Wiki resource without a Tag' do
|
||||
expect do
|
||||
post '/wiki', params: { title: 'Help:description_write', body: 'legacy' },
|
||||
headers: headers
|
||||
end.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescription, :count).by(0)
|
||||
it 'records a title-only edit in Tag history without adding body history' do
|
||||
snapshot = description.attributes
|
||||
expect { update_description(body: 'before', title: 'renamed') }
|
||||
.to change(TagVersion, :count).by(2)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
expect(page.tag_name.tag).to be_nil
|
||||
expect(page.body).to eq('legacy')
|
||||
expect(page.current_revision.body).to eq('legacy')
|
||||
end
|
||||
|
||||
it 'creates only legacy Wiki for a namespaced title with an existing Tag' do
|
||||
namespaced_name = create(:tag_name, name: 'help:foo')
|
||||
namespaced_tag = create(:tag, primary_tag_name: namespaced_name)
|
||||
|
||||
expect do
|
||||
post '/wiki', params: { title: namespaced_name.name, body: 'legacy' },
|
||||
headers: headers
|
||||
end.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagName, :count).by(0)
|
||||
.and change(TagDescription, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
expect(page).to have_attributes(tag_name_id: namespaced_name.id, body: 'legacy')
|
||||
expect(page.tag_name.tag).to eq(namespaced_tag)
|
||||
expect(page.current_revision.body).to eq('legacy')
|
||||
expect(page.wiki_versions.last.body).to eq('legacy')
|
||||
end
|
||||
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back both systems when #{ model.name } persistence fails" do
|
||||
tag_name
|
||||
before_state = stored_state
|
||||
aggregate_failures do
|
||||
fail_save(model) { create_description }
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /wiki/:id' do
|
||||
let!(:page) do
|
||||
tag
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name:, body: 'before', created_by_user: user)
|
||||
end
|
||||
let!(:first_revision) { page.current_revision }
|
||||
|
||||
let!(:description) do
|
||||
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
end
|
||||
|
||||
def update_description body: 'after', title: tag_name.name, base: first_revision.id
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title:, body:, message: 'edit', base_revision_id: base },
|
||||
headers: headers
|
||||
end
|
||||
|
||||
it 'increments the independent description sequence and shares revision identity' do
|
||||
expect(page.reload.version_no).to eq(1)
|
||||
expect(description.version_no).to eq(1)
|
||||
expect { update_description }
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
revision = page.reload.current_revision
|
||||
version = TagDescriptionVersion.find(revision.id)
|
||||
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
|
||||
expect(version).to have_attributes(
|
||||
body: 'after', version_no: 2, event_type: 'update', created_by_user_id: user.id)
|
||||
expect(page).to have_attributes(body: 'after', version_no: 2)
|
||||
expect(revision.body).to eq(version.body)
|
||||
expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body)
|
||||
expect(tag.reload.name('ja')).to eq('renamed')
|
||||
expect(description.reload.attributes).to eq(snapshot)
|
||||
end
|
||||
|
||||
it 'uses description identity even when its Wiki TagName no longer owns the Tag' do
|
||||
tag_name.update_columns(tag_id: nil)
|
||||
|
||||
expect { update_description }
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
|
||||
expect(TagDescriptionVersion.find(page.reload.current_revision.id))
|
||||
.to have_attributes(tag_id: tag.id, body: 'after', version_no: 2)
|
||||
end
|
||||
|
||||
it 'does not add body history for a title-only update' do
|
||||
revision_count = WikiRevision.count
|
||||
wiki_version_count = WikiVersion.count
|
||||
description_version_count = TagDescriptionVersion.count
|
||||
wiki_version_no = page.reload.version_no
|
||||
description_version_no = description.version_no
|
||||
current_revision_id = page.current_revision.id
|
||||
page_state = page.attributes
|
||||
description_state = description.attributes
|
||||
|
||||
update_description(body: 'before', title: 'Help:description_renamed')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(tag_name.reload.name).to eq('Help:description_renamed')
|
||||
expect(WikiRevision.count).to eq(revision_count)
|
||||
expect(WikiVersion.count).to eq(wiki_version_count)
|
||||
expect(TagDescriptionVersion.count).to eq(description_version_count)
|
||||
expect(page.reload).to have_attributes(body: 'before', version_no: wiki_version_no)
|
||||
expect(page.current_revision.id).to eq(current_revision_id)
|
||||
expect(page.reload.attributes).to eq(page_state)
|
||||
expect(description.reload).to have_attributes(
|
||||
body: 'before', version_no: description_version_no)
|
||||
expect(description.reload.attributes).to eq(description_state)
|
||||
end
|
||||
|
||||
it 'does not add body history for a TagName rename through tags' do
|
||||
revision_count = WikiRevision.count
|
||||
wiki_version_count = WikiVersion.count
|
||||
description_version_count = TagDescriptionVersion.count
|
||||
page_version_no = page.reload.version_no
|
||||
current_revision_id = page.current_revision.id
|
||||
|
||||
patch "/tags/#{ tag.id }",
|
||||
params: { name: 'description_tag_renamed' },
|
||||
headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(tag_name.reload.name).to eq('description_tag_renamed')
|
||||
expect(WikiRevision.count).to eq(revision_count)
|
||||
expect(WikiVersion.count).to eq(wiki_version_count)
|
||||
expect(TagDescriptionVersion.count).to eq(description_version_count)
|
||||
expect(page.reload.version_no).to eq(page_version_no)
|
||||
expect(page.current_revision.id).to eq(current_revision_id)
|
||||
expect(description.reload).to have_attributes(
|
||||
body: 'before', version_no: 1)
|
||||
end
|
||||
|
||||
it 'keeps both systems and the title unchanged on a stale revision conflict' do
|
||||
update_description(body: 'current body', base: first_revision.id)
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
stale_revision_id = first_revision.id
|
||||
before_state = stored_state
|
||||
|
||||
update_description(
|
||||
body: 'conflicting body',
|
||||
title: 'conflicting_rename',
|
||||
base: stale_revision_id)
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
expect(stored_state).to eq(before_state)
|
||||
expect(tag_name.reload.name).not_to eq('conflicting_rename')
|
||||
end
|
||||
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back both systems when #{ model.name } persistence fails" do
|
||||
before_state = stored_state
|
||||
aggregate_failures do
|
||||
fail_save(model) do
|
||||
update_description(body: 'rollback body', title: 'rollback_rename')
|
||||
end
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
[['identical body', 'before'],
|
||||
['normalised-equivalent body', "before\n\n"]].each do |label, body|
|
||||
it "does not write histories for #{ label }" do
|
||||
before_state = stored_state
|
||||
page_state = page.reload.attributes
|
||||
description_state = description.reload.attributes
|
||||
current_revision_id = page.current_revision.id
|
||||
wiki_lines = WikiLine.order(:id).map(&:attributes)
|
||||
revision_lines = WikiRevisionLine.order(:id).map(&:attributes)
|
||||
page_version_no = page.version_no
|
||||
description_version_no = description.version_no
|
||||
|
||||
update_description(body:)
|
||||
|
||||
['conflicting body', 'after'].each do |body|
|
||||
it "rejects a stale revision even for #{ body } without a partial rename" do
|
||||
update_description
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(WikiRevision.count).to eq(before_state[1].length)
|
||||
expect(WikiVersion.count).to eq(before_state[2].length)
|
||||
expect(TagDescriptionVersion.count).to eq(before_state[4].length)
|
||||
expect(page.reload.version_no).to eq(page_version_no)
|
||||
expect(description.reload.version_no).to eq(description_version_no)
|
||||
expect(page.current_revision.id).to eq(current_revision_id)
|
||||
expect(page.reload.attributes).to eq(page_state)
|
||||
expect(description.reload.attributes).to eq(description_state)
|
||||
expect(WikiLine.order(:id).map(&:attributes)).to eq(wiki_lines)
|
||||
expect(WikiRevisionLine.order(:id).map(&:attributes)).to eq(revision_lines)
|
||||
before_state = stored_state
|
||||
update_description(body:, title: 'conflicting rename')
|
||||
expect(response).to have_http_status(:conflict)
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
[TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back the rename and body after #{ model.name } persistence fails" do
|
||||
before_state = stored_state
|
||||
failure = Class.new(StandardError)
|
||||
callback = ->(_record) { raise failure }
|
||||
model.set_callback(:save, :after, callback)
|
||||
begin
|
||||
expect { update_description(title: 'rollback rename') }.to raise_error(failure)
|
||||
expect(stored_state).to eq(before_state)
|
||||
ensure
|
||||
model.skip_callback(:save, :after, callback)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag description native API', type: :request do
|
||||
include WikiRecords
|
||||
include TagGroupRecords
|
||||
let!(:author) { create_member_user! }
|
||||
let(:editor) { create(:user, :member, name: 'description editor') }
|
||||
let(:tag_name) { create(:tag_name, name: 'native_description') }
|
||||
@@ -9,8 +11,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
let(:endpoint) { "/tags/#{ tag.id }/description" }
|
||||
|
||||
def stored_state
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
|
||||
WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model|
|
||||
[TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model|
|
||||
model.order(:id).map(&:attributes)
|
||||
end
|
||||
end
|
||||
@@ -84,7 +85,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
describe 'native reads' do
|
||||
let!(:description) do
|
||||
TagDescription.create!(
|
||||
id: tag.id + 1_000_000, tag:, language_code: 'ja', body: 'native current',
|
||||
tag:, language_code: 'ja', body: 'native current',
|
||||
version_no: 1, created_by_user_id: author.id, updated_by_user_id: editor.id)
|
||||
end
|
||||
let!(:version) do
|
||||
@@ -95,15 +96,14 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
|
||||
|
||||
it 'reads the current resource by Tag ID without a WikiPage' do
|
||||
expect(description.id).not_to eq(tag.id)
|
||||
expect(WikiPage.exists?(id: description.id)).to be(false)
|
||||
without_legacy_reads { native_get(endpoint) }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_current(description)
|
||||
end
|
||||
|
||||
it 'does not interpret a compatibility handle as a Tag ID' do
|
||||
other_tag = create(:tag, id: description.id, primary_name: 'handle_collision')
|
||||
other_tag = create(:tag, primary_name: 'handle_collision')
|
||||
description.update_columns(id: other_tag.id)
|
||||
other = TagDescription.create!(
|
||||
tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1,
|
||||
created_by_user_id: author.id, updated_by_user_id: author.id)
|
||||
@@ -153,32 +153,6 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
expect_current(description)
|
||||
end
|
||||
|
||||
[:id, :name].each do |lookup|
|
||||
it "ignores divergent legacy body and history for #{ lookup } lookup" do
|
||||
page = WikiPage.create!(
|
||||
id: description.id, tag_name:, body: 'old current',
|
||||
created_user: author, updated_user: author)
|
||||
revision = WikiRevision.create!(
|
||||
wiki_page: page, created_user: author, kind: :content,
|
||||
lines_count: 1, tree_sha256: Digest::SHA256.hexdigest('old revision'))
|
||||
line = WikiLine.create!(body: 'old revision', sha256: revision.tree_sha256)
|
||||
WikiRevisionLine.create!(wiki_revision: revision, wiki_line: line, position: 0)
|
||||
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: author)
|
||||
path = lookup == :id ? endpoint : name_endpoint
|
||||
action = lookup == :id ? 'show' : 'show_by_title'
|
||||
|
||||
without_legacy_reads { native_get(path, action:) }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_current(description)
|
||||
original = json
|
||||
page.update_columns(body: 'changed old current')
|
||||
line.update_columns(body: 'changed old revision')
|
||||
revision.update_columns(message: 'changed old history', created_at: 1.day.ago)
|
||||
without_legacy_reads { native_get(path, action:) }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq(original)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'missing native resources' do
|
||||
@@ -206,35 +180,24 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
context 'without an existing description' do
|
||||
before { tag }
|
||||
|
||||
it 'creates both current resources and histories with matching compatibility IDs' do
|
||||
|
||||
it 'creates native history that is immediately available through the facade' do
|
||||
expect { native_write(body: 'created natively') }
|
||||
.to change(TagDescription, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
.and change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
page = WikiPage.find(description.id)
|
||||
revision = page.current_revision
|
||||
version = TagDescriptionVersion.find(revision.id)
|
||||
expect(description).to have_attributes(
|
||||
body: 'created natively', version_no: 1,
|
||||
created_by_user_id: editor.id, updated_by_user_id: editor.id)
|
||||
expect(version).to have_attributes(
|
||||
tag_id: tag.id, language_code: 'ja', body: description.body,
|
||||
version_no: 1, event_type: 'create', created_by_user_id: editor.id)
|
||||
expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body)
|
||||
expect(revision.body).to eq(version.body)
|
||||
expect(page.wiki_versions.last.body).to eq(version.body)
|
||||
expect_current(description)
|
||||
|
||||
version = current_description_version(description)
|
||||
expect(version).to have_attributes(
|
||||
body: 'created natively', version_no: 1, event_type: 'create',
|
||||
created_by_user_id: editor.id)
|
||||
get "/wiki/#{ description.id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => description.body, 'revision_id' => version.id)
|
||||
end
|
||||
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
||||
[TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back native create when #{ model.name } persistence fails" do
|
||||
headers
|
||||
before_state = stored_state
|
||||
@@ -249,7 +212,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
context 'with an existing description' do
|
||||
let!(:page) do
|
||||
tag
|
||||
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author)
|
||||
seed_description(tag_name:, body: 'before', created_by_user: author)
|
||||
end
|
||||
let!(:description) do
|
||||
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
@@ -259,22 +222,20 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
expect(page.reload.version_no).to eq(1)
|
||||
expect(description.version_no).to eq(1)
|
||||
expect { native_write(body: 'after') }
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
.to change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(description.reload).to have_attributes(
|
||||
body: 'after', version_no: 2,
|
||||
created_by_user_id: author.id, updated_by_user_id: editor.id)
|
||||
expect(page.reload).to have_attributes(body: 'after', version_no: 2)
|
||||
|
||||
revision = page.current_revision
|
||||
revision = current_description_version(page)
|
||||
version = TagDescriptionVersion.find(revision.id)
|
||||
expect(version).to have_attributes(
|
||||
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
|
||||
body: 'after', created_by_user_id: editor.id)
|
||||
expect(revision.body).to eq(version.body)
|
||||
expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body)
|
||||
expect(description_versions(page).order(:version_no).last.body).to eq(version.body)
|
||||
get "/wiki/#{ description.id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => 'after', 'revision_id' => version.id)
|
||||
@@ -285,7 +246,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
before_state = stored_state
|
||||
page_state = page.reload.attributes
|
||||
description_state = description.reload.attributes
|
||||
current_revision_id = page.current_revision.id
|
||||
current_revision_id = current_description_version(page).id
|
||||
|
||||
native_write(body:)
|
||||
|
||||
@@ -293,11 +254,11 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
expect(stored_state).to eq(before_state)
|
||||
expect(page.reload.attributes).to eq(page_state)
|
||||
expect(description.reload.attributes).to eq(description_state)
|
||||
expect(page.current_revision.id).to eq(current_revision_id)
|
||||
expect(current_description_version(page).id).to eq(current_revision_id)
|
||||
end
|
||||
end
|
||||
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
||||
[TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back native update when #{ model.name } persistence fails" do
|
||||
headers
|
||||
before_state = stored_state
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
include TagGroupRecords
|
||||
include_context 'Ainu locale contract'
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'japanese_member') }
|
||||
let!(:ainu_name) { add_ainu_name(tag, 'ainu_member') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:members) { [{ 'tag_id' => tag.id }] }
|
||||
let(:placements) do
|
||||
[{ 'section_name' => 'section', 'description' => nil, 'children' => [
|
||||
{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_id' => tag.id, 'children' => [] }] }] }]
|
||||
end
|
||||
let!(:group) do
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: user)
|
||||
TagGroupOperation.create!(
|
||||
ainu_locale, name: 'locale group', description: nil, members:, placements:, actor: user)
|
||||
end
|
||||
|
||||
it 'uses selected-language names in native collection and nested placements' do
|
||||
["/tag_groups/#{ group.id }", '/tag_groups'].each do |path|
|
||||
get path, params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
representation = json.is_a?(Array) ? json.sole : json
|
||||
expect(representation.fetch('members').sole.fetch('tag').fetch('name'))
|
||||
.to eq('ainu_member')
|
||||
node = representation.fetch('placements').sole.fetch('children').sole
|
||||
expect(node.fetch('tag').fetch('name')).to eq('ainu_member')
|
||||
expect(node.fetch('children').sole.fetch('tag').fetch('name')).to eq('ainu_member')
|
||||
expect(response.body).not_to include('japanese_member')
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses snapshot names recursively for history and current names for the latest view' do
|
||||
version = group.versions.sole
|
||||
ainu_name.update!(name: 'renamed_ainu')
|
||||
get "/wiki/#{ group.id }", params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body')).to include('renamed_ainu')
|
||||
get "/wiki/#{ group.id }", params: { locale: 'ain-Kana', version: version.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').scan('ainu_member').length).to eq(2)
|
||||
expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member')
|
||||
get "/wiki/#{ group.id }/diff", params: { locale: 'ain-Kana', from: version.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context'])
|
||||
end
|
||||
|
||||
['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix|
|
||||
it "round trips #{ prefix } through create, lookup, search and update" do
|
||||
post '/wiki', params: {
|
||||
locale: 'ain-Kana', title: "#{ prefix }parent", body: '- tag_group:locale group' },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
expect(json.fetch('body')).to include('タググループ:locale group')
|
||||
path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }"
|
||||
get path, params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('id')).to eq(id)
|
||||
get "#{ path }/exists", params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:no_content)
|
||||
get '/wiki/search', params: { locale: 'ain-Kana', title: "#{ prefix }parent" }
|
||||
expect(json.map { _1.fetch('id') }).to eq([id])
|
||||
put "/wiki/#{ id }", params: {
|
||||
locale: 'ain-Kana', title: "#{ prefix }renamed", body: '- ainu_member' },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
get "/tag_groups/#{ id }", params: { locale: 'ain-Kana' }
|
||||
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
|
||||
end
|
||||
end
|
||||
|
||||
it 'resolves Markdown names in the requested language and rolls back invalid input' do
|
||||
parsed = TagGroupMarkdownParser.parse(ainu_locale, body: '- ainu_member')
|
||||
expect(parsed.fetch(:members)).to eq(members)
|
||||
expect do
|
||||
TagGroupMarkdownParser.parse(ainu_locale, body: '- japanese_member')
|
||||
end.to raise_error(TagGroupMarkdownParser::InvalidDocument)
|
||||
before_state = group_state
|
||||
put "/wiki/#{ group.id }", params: {
|
||||
locale: 'ain-Kana', title: 'タググループ:partial rename', body: '- japanese_member' },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'passes locale into native validation and facade compatibility checks' do
|
||||
post '/tag_groups', params: {
|
||||
locale: 'ain-Kana', name: 'native', description: nil, members:, placements: },
|
||||
headers:, as: :json
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
|
||||
get "/wiki/#{ id }", params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = json.fetch('body')
|
||||
put "/wiki/#{ id }", params: { locale: 'ain-Kana', title: 'tag_group:native', body: },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroup.find(id).version_no).to eq(1)
|
||||
end
|
||||
|
||||
it 'does not substitute current or Japanese names for a missing historical language' do
|
||||
other = create(:tag, primary_name: 'historical_japanese')
|
||||
TagVersioning.ensure_snapshot!(other, created_by_user: user)
|
||||
old_group = TagGroupOperation.create!(
|
||||
Locale.nipponese, name: 'old group', description: nil,
|
||||
members: [{ 'tag_id' => other.id }],
|
||||
placements: [{ 'tag_id' => other.id, 'children' => [] }], actor: user)
|
||||
add_ainu_name(other, 'current_ainu')
|
||||
version = old_group.versions.sole
|
||||
representation = TagGroupRepr.historical(ainu_locale, version)
|
||||
expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil
|
||||
expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil
|
||||
get "/wiki/#{ old_group.id }", params: { locale: 'ain-Kana', version: version.id }
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).not_to include('historical_japanese', 'current_ainu')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,148 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :request do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:series) { group_tag('ぼざろクリーチャーシリーズ') }
|
||||
let!(:deer) { group_tag('洗澡鹿') }
|
||||
let!(:nizika) { group_tag('伊地知ニジカ') }
|
||||
let!(:wanted) { group_tag('タグ希望') }
|
||||
let!(:character) { group_tag('キャラクター名希望') }
|
||||
let!(:music) { group_tag('楽曲情報希望') }
|
||||
let!(:bot) { group_tag('bot操作') }
|
||||
let!(:unknown) { group_tag('ニジラー情報不詳') }
|
||||
let!(:child_id) { seed_group(name: '伊地知ニジカ亜種', description: '子の説明') }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def tag_node tag, children = []
|
||||
{ 'tag_id' => tag.id, 'children' => children }
|
||||
end
|
||||
|
||||
def tag_member tag
|
||||
{ 'tag_id' => tag.id, 'version_no' => tag.version_no }
|
||||
end
|
||||
|
||||
def check_round_trip placements:, members:, markdown:, description: nil
|
||||
id = seed_group(name: '変換元', placements:, members:, description:)
|
||||
without_group_legacy_reads { get group_title_path('変換元') }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('id' => id)
|
||||
body = json.fetch('body')
|
||||
expect(body).not_to include("\r")
|
||||
expect(body.lines.map(&:chomp)).to all(satisfy { |line| line == line.rstrip })
|
||||
# A trailing LF or a blank line between blocks is semantically irrelevant.
|
||||
expect(body.lines.map(&:chomp).reject(&:empty?)).to eq(
|
||||
markdown.lines.map(&:chomp).reject(&:empty?))
|
||||
|
||||
post '/wiki', params: { title: 'タググループ:再解析', body: },
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
expect(response).to have_http_status(:created)
|
||||
parsed = group_rows('tag_groups', name: '再解析').sole
|
||||
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
|
||||
end
|
||||
|
||||
it 'renders a list Section under a Tag with two-space indentation' do
|
||||
check_round_trip(
|
||||
members: [series, deer].map { tag_member(_1) },
|
||||
placements: [tag_node(series, [
|
||||
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
|
||||
'children' => [tag_node(deer)] }])],
|
||||
markdown: <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ亜種:
|
||||
- 洗澡鹿
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'renders a child Tag Group distinctly from its parent Tags' do
|
||||
check_round_trip(
|
||||
members: [tag_member(series), tag_member(nizika),
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 }],
|
||||
placements: [tag_node(series, [tag_node(nizika, [
|
||||
{ 'tag_group_id' => child_id, 'children' => [] }])])],
|
||||
markdown: <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ
|
||||
- タググループ:伊地知ニジカ亜種
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'renders top-level heading Sections, descriptions and ordered nested Tags' do
|
||||
check_round_trip(
|
||||
description: '全体の説明',
|
||||
members: [wanted, character, music, bot, unknown].map { tag_member(_1) },
|
||||
placements: [
|
||||
{ 'section_name' => '要求', 'description' => '要求の説明',
|
||||
'children' => [tag_node(wanted, [tag_node(character), tag_node(music)])] },
|
||||
{ 'section_name' => '情報', 'description' => nil,
|
||||
'children' => [tag_node(bot), tag_node(unknown)] }],
|
||||
markdown: <<~MARKDOWN)
|
||||
全体の説明
|
||||
|
||||
## 要求
|
||||
要求の説明
|
||||
|
||||
- タグ希望
|
||||
- キャラクター名希望
|
||||
- 楽曲情報希望
|
||||
|
||||
## 情報
|
||||
- bot操作
|
||||
- ニジラー情報不詳
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'renders nested heading Sections and all repeated Tag placements' do
|
||||
check_round_trip(
|
||||
members: [tag_member(deer)],
|
||||
placements: [
|
||||
{ 'section_name' => '親', 'description' => '親の説明', 'children' => [
|
||||
tag_node(deer),
|
||||
{ 'section_name' => '子', 'description' => '子の説明',
|
||||
'children' => [tag_node(deer)] }] }],
|
||||
markdown: <<~MARKDOWN)
|
||||
## 親
|
||||
親の説明
|
||||
|
||||
- 洗澡鹿
|
||||
|
||||
### 子
|
||||
子の説明
|
||||
|
||||
- 洗澡鹿
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'round trips a top-level list followed by heading Sections' do
|
||||
check_round_trip(
|
||||
members: [tag_member(series), tag_member(deer)],
|
||||
placements: [tag_node(series),
|
||||
{ 'section_name' => '区分', 'description' => nil,
|
||||
'children' => [tag_node(deer)] }],
|
||||
markdown: "- ぼざろクリーチャーシリーズ\n\n## 区分\n\n- 洗澡鹿")
|
||||
end
|
||||
|
||||
it 'rejects mixed top-level heading Sections and Tags without losing structure' do
|
||||
id = seed_group(
|
||||
name: '混在構造', members: [tag_member(series)],
|
||||
placements: [
|
||||
{ 'section_name' => '区分', 'description' => nil, 'children' => [] },
|
||||
tag_node(series)])
|
||||
|
||||
get group_title_path('混在構造')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
|
||||
end
|
||||
|
||||
it 'rejects membership without a placement instead of omitting it' do
|
||||
id = seed_group(name: '未配置構造', members: [tag_member(series)])
|
||||
|
||||
get group_title_path('未配置構造')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,179 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
include WikiRecords
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:old_tag) { group_tag('旧タグ') }
|
||||
let!(:new_tag) { group_tag('新タグ') }
|
||||
let!(:page) { create_markdown_group('読取り契約', '- 旧タグ') }
|
||||
let!(:first_revision) { page.versions.sole }
|
||||
let!(:last_revision) do
|
||||
parsed = TagGroupMarkdownParser.parse(Locale.nipponese, body: '- 新タグ')
|
||||
TagGroupOperation.update!(
|
||||
Locale.nipponese,
|
||||
group: page, name: page.name, actor: user,
|
||||
**parsed.slice(:description, :members, :placements))
|
||||
page.versions.order(:version_no).last
|
||||
end
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
[:id, :title].each do |lookup|
|
||||
context "with #{ lookup } lookup" do
|
||||
let(:path) { lookup == :id ? "/wiki/#{ page.id }" : group_title_path('読取り契約') }
|
||||
|
||||
it 'reads current structure without consulting migrated Wiki tables' do
|
||||
without_group_legacy_reads { get path }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => page.id, 'title' => 'タググループ:読取り契約',
|
||||
'revision_id' => last_revision.id, 'pred' => first_revision.id, 'succ' => nil)
|
||||
expect(json.fetch('body').strip).to eq('- 新タグ')
|
||||
end
|
||||
|
||||
it 'reads the old snapshot rather than current membership or placement' do
|
||||
without_group_legacy_reads { get path, params: { version: first_revision.id } }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').strip).to eq('- 旧タグ')
|
||||
expect(json).to include(
|
||||
'revision_id' => first_revision.id, 'pred' => nil, 'succ' => last_revision.id,
|
||||
'updated_at' => first_revision.created_at.iso8601(3))
|
||||
end
|
||||
|
||||
it 'checks existence without consulting migrated Wiki tables' do
|
||||
without_group_legacy_reads { get "#{ path }/exists" }
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'does not fall back to legacy history when a group version is missing' do
|
||||
group_connection.execute(
|
||||
"DELETE FROM tag_group_versions WHERE id = #{ first_revision.id }")
|
||||
without_group_legacy_reads { get path, params: { version: first_revision.id } }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
['/wiki', '/wiki/search'].each do |path|
|
||||
it "lists migrated groups once with current titles at #{ path }" do
|
||||
get path, params: { title: 'タググループ:読取り' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
rows = json.select { _1.fetch('title') == 'タググループ:読取り契約' }
|
||||
expect(rows.size).to eq(1)
|
||||
expect(rows.sole).to include('id' => page.id)
|
||||
expect(response.body).not_to include('legacy stale')
|
||||
end
|
||||
end
|
||||
|
||||
it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do
|
||||
TagDescriptionOperation.create!(
|
||||
Locale.nipponese, tag: new_tag, body: 'tag description', created_by_user: user)
|
||||
get '/wiki'
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.fetch('title') }).to include(
|
||||
'タググループ:読取り契約', '新タグ')
|
||||
end
|
||||
|
||||
|
||||
it 'finds groups by a partial prefix of their displayed title' do
|
||||
get '/wiki', params: { title: 'タググル' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.fetch('title') }).to include('タググループ:読取り契約')
|
||||
expect(json.find { _1.fetch('title') == 'タググループ:読取り契約' })
|
||||
.to include('updated_at' => be_present)
|
||||
end
|
||||
|
||||
it 'does not restore a stale namespaced Wiki page after a group rename' do
|
||||
group = create_markdown_group('旧名称', '正本')
|
||||
TagGroupOperation.update!(
|
||||
Locale.nipponese,
|
||||
group:, name: '新名称', description: '正本', members: [], placements: [], actor: user)
|
||||
|
||||
get group_title_path('旧名称')
|
||||
expect(response).to have_http_status(:not_found)
|
||||
get "#{ group_title_path('旧名称') }/exists"
|
||||
expect(response).to have_http_status(:not_found)
|
||||
get group_title_path('新名称')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').strip).to eq('正本')
|
||||
end
|
||||
|
||||
it 'orders mixed resource history by timestamp rather than independent IDs' do
|
||||
TagDescriptionOperation.create!(
|
||||
Locale.nipponese, tag: new_tag, body: 'description', created_by_user: user)
|
||||
TagGroupVersion.update_all(created_at: 3.days.ago)
|
||||
TagDescriptionVersion.update_all(created_at: 1.day.ago)
|
||||
|
||||
get '/wiki/changes'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
|
||||
expect(timestamps).to eq(timestamps.sort.reverse)
|
||||
titles = json.map { _1.dig('wiki_page', 'title') }
|
||||
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
|
||||
end
|
||||
|
||||
it 'renders nullable group editors without raising' do
|
||||
group_connection.execute(
|
||||
"UPDATE tag_groups SET created_by_user_id = NULL, updated_by_user_id = NULL " +
|
||||
"WHERE id = #{ page.id }")
|
||||
group_connection.execute(
|
||||
"UPDATE tag_group_versions SET created_by_user_id = NULL " +
|
||||
"WHERE tag_group_id = #{ page.id }")
|
||||
|
||||
get "/tag_groups/#{ page.id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('created_by_user' => nil, 'updated_by_user' => nil)
|
||||
get "/wiki/title/#{ CGI.escapeURIComponent('タググループ:読取り契約') }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'reads group version IDs, editor and timestamps in scoped changes' do
|
||||
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
|
||||
expect(json.first).to include(
|
||||
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
|
||||
'timestamp' => last_revision.created_at.iso8601(3),
|
||||
'user' => { 'id' => user.id, 'name' => user.name },
|
||||
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
|
||||
end
|
||||
|
||||
|
||||
[false, true].each do |explicit|
|
||||
it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do
|
||||
params = { from: first_revision.id }
|
||||
params[:to] = last_revision.id if explicit
|
||||
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'wiki_page_id' => page.id, 'older_revision_id' => first_revision.id,
|
||||
'newer_revision_id' => last_revision.id)
|
||||
differences = json.fetch('diff').map { [_1.fetch('type'), _1.fetch('content').strip] }
|
||||
expect(differences).to include(['removed', '- 旧タグ'], ['added', '- 新タグ'])
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not use a version belonging to another group' do
|
||||
other_id = seed_group(name: '別グループ', description: '別本文')
|
||||
other_version = group_version(other_id, 1)
|
||||
get group_title_path('読取り契約'), params: { version: other_version.fetch('id') }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
get "/wiki/#{ page.id }/diff",
|
||||
params: { from: other_version.fetch('id'), to: last_revision.id }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'resolves a group without any compatibility Wiki page' do
|
||||
id = seed_group(name: '独立グループ', description: '独立した説明')
|
||||
without_group_legacy_reads { get group_title_path('独立グループ') }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('id' => id, 'title' => 'タググループ:独立グループ')
|
||||
expect(json.fetch('body').strip).to eq('独立した説明')
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
@@ -0,0 +1,298 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
include TagGroupRecords
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('洗澡鹿') }
|
||||
let!(:other_tag) { group_tag('伊地知ニジカ') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def create_group title: 'タググループ:新規', body: '- 洗澡鹿', request_headers: headers
|
||||
post '/wiki', params: { title:, body: }, headers: request_headers
|
||||
end
|
||||
|
||||
def expect_rejected_without_writes
|
||||
before_state = group_state
|
||||
yield
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
expect(response.media_type).to eq('application/json')
|
||||
expect(response.body).not_to be_empty
|
||||
next if response.body.empty?
|
||||
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error', 'errors' => a_kind_of(Hash),
|
||||
'base_errors' => a_kind_of(Array))
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates the group, snapshot and native version history' do
|
||||
expect { create_group }
|
||||
.to change(TagGroup, :count).by(1)
|
||||
.and change(TagGroupVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
group = group_rows('tag_groups', name: '新規').sole
|
||||
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
|
||||
expect_group_snapshot(group.fetch('id'), number: 1, members:, placements:)
|
||||
version = group_version(group.fetch('id'), 1)
|
||||
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
|
||||
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
|
||||
get group_title_path('新規')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body')).to eq('- 洗澡鹿')
|
||||
end
|
||||
|
||||
it 'keeps repeated placements but only one membership and one snapshot member' do
|
||||
create_group(body: "- 洗澡鹿\n# 再掲\n- 洗澡鹿")
|
||||
expect(response).to have_http_status(:created)
|
||||
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
|
||||
expect_group_snapshot(
|
||||
id, number: 1, members:,
|
||||
placements: placements + [
|
||||
{ 'section_name' => '再掲', 'description' => nil, 'children' => placements }])
|
||||
end
|
||||
|
||||
it 'resolves a Japanese alias through tag_names.tag_id' do
|
||||
create(:tag_name, :alias, tag:, name: '鹿の別名')
|
||||
create_group(body: '- 鹿の別名')
|
||||
expect(response).to have_http_status(:created)
|
||||
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
|
||||
expect_group_snapshot(id, number: 1, members:, placements:)
|
||||
get group_title_path('新規')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').strip).to eq('- 洗澡鹿')
|
||||
end
|
||||
|
||||
{
|
||||
'unknown Tag' => '- 解決不能',
|
||||
'unknown Tag Group' => '- タググループ:存在しない',
|
||||
'empty Section' => '- :',
|
||||
'unsupported placement syntax' => "```\n洗澡鹿\n```",
|
||||
'six levels' => "- 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n" +
|
||||
" - 洗澡鹿\n - 洗澡鹿",
|
||||
'blank body' => '',
|
||||
'normalised blank body' => "\r\n\r\n" }.each do |label, body|
|
||||
it "rejects #{ label } without fallback or partial create" do
|
||||
expect_rejected_without_writes { create_group(body:) }
|
||||
end
|
||||
end
|
||||
|
||||
['', 'タググループ:', "タググループ:#{ '長' * 256 }"].each do |title|
|
||||
it "rejects an invalid required title of length #{ title.length }" do
|
||||
expect_rejected_without_writes { create_group(title:) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a duplicate group name even without a matching legacy Wiki' do
|
||||
seed_group(name: '新規', description: '既存')
|
||||
expect_rejected_without_writes { create_group }
|
||||
end
|
||||
|
||||
it 'accepts the existing five-level placement depth boundary' do
|
||||
body = (0...5).map { "#{ ' ' * _1 }- 洗澡鹿" }.join("\n")
|
||||
create_group(body:)
|
||||
expect(response).to have_http_status(:created)
|
||||
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
|
||||
tree = []
|
||||
5.times { tree = [{ 'tag_id' => tag.id, 'children' => tree }] }
|
||||
expect_group_snapshot(id, number: 1, members:, placements: tree)
|
||||
end
|
||||
|
||||
context 'updating a migrated group' do
|
||||
let!(:page) { create_markdown_group('更新', '- 洗澡鹿') }
|
||||
let!(:old_revision) { page.versions.order(:version_no).last }
|
||||
|
||||
|
||||
def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers
|
||||
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
|
||||
end
|
||||
|
||||
[1, 3].each do |repetitions|
|
||||
it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do
|
||||
body = group_markdown(page)
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id) do
|
||||
repetitions.times do
|
||||
update_group(body:)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps identical Markdown unchanged with a stale base_revision_id' do
|
||||
update_group
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = group_markdown(page)
|
||||
expect(page.versions.order(:version_no).last.id).not_to eq(old_revision.id)
|
||||
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id) do
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: "タググループ:#{ page.name }", body:, base_revision_id: old_revision.id },
|
||||
headers: headers
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps the original editor on identical Markdown from another member' do
|
||||
editor = create(:user, :member)
|
||||
body = group_markdown(page)
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id) do
|
||||
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not add history for Wiki content already changed through Native' do
|
||||
changed_members = [{ 'tag_id' => other_tag.id }]
|
||||
changed_placements = [{ 'tag_id' => other_tag.id, 'children' => [] }]
|
||||
put "/tag_groups/#{ page.id }",
|
||||
params: { name: '更新', description: nil,
|
||||
members: changed_members, placements: changed_placements },
|
||||
headers:, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
page.id, number: 2,
|
||||
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
|
||||
placements: changed_placements)
|
||||
body = group_markdown(page)
|
||||
expect(body).to eq('- 伊地知ニジカ')
|
||||
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id) { update_group(body:) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'replaces current membership and placement, retaining the old snapshot' do
|
||||
old = group_version(page.id, 1)
|
||||
update_group
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
page.id, number: 2,
|
||||
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
|
||||
placements: [{ 'tag_id' => other_tag.id, 'children' => [] }])
|
||||
expect(group_version(page.id, 1)).to eq(old)
|
||||
expect(group_version(page.id, 2)).to include(
|
||||
'event_type' => 'update', 'created_by_user_id' => user.id)
|
||||
expect(group_markdown(page).strip).to eq('- 伊地知ニジカ')
|
||||
expect(page.versions.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'accepts the current base_revision_id used by the existing frontend' do
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: "タググループ:#{ page.name }", body: '- 伊地知ニジカ',
|
||||
base_revision_id: old_revision.id }, headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2)
|
||||
end
|
||||
|
||||
it 'ignores a stale base_revision_id without rejecting the update' do
|
||||
update_group
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: "タググループ:#{ page.name }", body: '- 洗澡鹿',
|
||||
base_revision_id: old_revision.id }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(3)
|
||||
expect(group_markdown(page).strip).to eq('- 洗澡鹿')
|
||||
end
|
||||
|
||||
it 'records the editor and update time' do
|
||||
editor = create(:user, :member, name: '編輯者')
|
||||
travel_to(Time.current + 1.hour) do
|
||||
update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
||||
expect(response).to have_http_status(:ok)
|
||||
current = group_rows('tag_groups', id: page.id).sole
|
||||
version = group_version(page.id, 2)
|
||||
expect(current).to include('updated_by_user_id' => editor.id)
|
||||
expect(version).to include('created_by_user_id' => editor.id)
|
||||
expect(current.fetch('updated_at')).to be_within(1.second).of(Time.current)
|
||||
expect(version.fetch('created_at')).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
['- 不明なタグ', '- タググループ:不明', '', '- :'].each do |body|
|
||||
it "rejects invalid update #{ body.inspect } atomically" do
|
||||
expect_rejected_without_writes { update_group(body:) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects direct self inclusion' do
|
||||
expect_rejected_without_writes { update_group(body: '- タググループ:更新') }
|
||||
end
|
||||
|
||||
it 'rejects indirect cyclic membership' do
|
||||
seed_group(
|
||||
name: '子', members: [{ 'tag_group_id' => page.id, 'version_no' => 1 }],
|
||||
placements: [{ 'tag_group_id' => page.id, 'children' => [] }])
|
||||
expect_rejected_without_writes { update_group(body: '- タググループ:子') }
|
||||
end
|
||||
|
||||
it 'rejects duplicate rename without retaining a partial title or version' do
|
||||
seed_group(name: '既存', description: '既存')
|
||||
expect_rejected_without_writes { update_group(title: 'タググループ:既存') }
|
||||
end
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
context "#{ operation } access and atomicity" do
|
||||
let!(:page) do
|
||||
next unless operation == :update
|
||||
|
||||
create_markdown_group('更新', '- 洗澡鹿')
|
||||
end
|
||||
let!(:child_id) { seed_group(name: '子グループ', description: '子') }
|
||||
|
||||
def write_group operation, request_headers
|
||||
body = "- 伊地知ニジカ\n- タググループ:子グループ"
|
||||
if operation == :create
|
||||
create_group(body:, request_headers:)
|
||||
else
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: 'タググループ:更新', body: }, headers: request_headers
|
||||
end
|
||||
end
|
||||
|
||||
[nil, :guest, :banned].each do |role|
|
||||
it "rejects #{ role || 'anonymous' } without writes" do
|
||||
actor = case role
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
before_state = group_state
|
||||
write_group(operation, request_headers)
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a banned IP even for a member' do
|
||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||
before_state = group_state
|
||||
write_group(operation, headers)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
tables = TagGroupRecords::GROUP_TABLES
|
||||
tables.each do |table|
|
||||
it "rolls back all writes after a real #{ table } write fails" do
|
||||
expect_atomic_group_write(table) { write_group(operation, headers) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,204 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group write integrity', type: :request do
|
||||
include WikiRecords
|
||||
include TagGroupRecords
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('洗澡鹿') }
|
||||
let!(:other_tag) { group_tag('伊地知ニジカ') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def native_payload name: '更新', nodes: placements, member_rows: members
|
||||
{ name:, description: nil, members: member_rows.map do |member|
|
||||
member.except('version_no')
|
||||
end, placements: nodes }
|
||||
end
|
||||
|
||||
def put_wiki id, title:, body: '- 伊地知ニジカ', base: nil
|
||||
put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers
|
||||
end
|
||||
|
||||
def expect_no_writes
|
||||
before_state = group_state
|
||||
yield
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
context 'resource identity' do
|
||||
|
||||
it 'rejects an unprefixed update to an independent group' do
|
||||
id = seed_group(name: '独立', members:, placements:)
|
||||
expect_no_writes { put_wiki(id, title: '独立') }
|
||||
end
|
||||
|
||||
|
||||
it 'renames the group and its facade title through the operation' do
|
||||
page = create_markdown_group('更新', '- 洗澡鹿')
|
||||
|
||||
put_wiki(page.id, title: 'タググループ:改名')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroup.find(page.id)).to have_attributes(name: '改名', version_no: 2)
|
||||
expect("タググループ:#{ page.reload.name }").to eq('タググループ:改名')
|
||||
expect(group_markdown(page)).to eq('- 伊地知ニジカ')
|
||||
end
|
||||
|
||||
|
||||
|
||||
it 'keeps a description update separate from a colliding group' do
|
||||
page = seed_description(
|
||||
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
|
||||
description = TagDescription.find_by!(tag_id: tag.id)
|
||||
id = seed_group(name: '更新', id: description.id, members:, placements:)
|
||||
group_before = TagGroup.find(id).attributes
|
||||
|
||||
put_wiki(id, title: '洗澡鹿', body: 'description after')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagDescription.find(id).body).to eq('description after')
|
||||
expect(page.reload.body).to eq('description after')
|
||||
expect(TagGroup.find(id).attributes).to eq(group_before)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
||||
it 'rolls back a facade create when its saved structure cannot be rendered' do
|
||||
allow(TagGroupMarkdownRenderer).to receive(:render)
|
||||
.and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported')
|
||||
expect_no_writes do
|
||||
post '/wiki', params: { title: 'タググループ:新規', body: '- 洗澡鹿' }, headers: headers
|
||||
end
|
||||
end
|
||||
|
||||
it 'rolls back a facade update when its saved structure cannot be rendered' do
|
||||
id = seed_group(name: '更新', members:, placements:)
|
||||
allow(TagGroupMarkdownRenderer).to receive(:render)
|
||||
.and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported')
|
||||
expect_no_writes { put_wiki(id, title: 'タググループ:改名') }
|
||||
end
|
||||
|
||||
context 'identifier Markdown' do
|
||||
['[洗澡鹿](https://example.com)', '*洗澡鹿*', '**洗澡鹿**', '`洗澡鹿`',
|
||||
'',
|
||||
'[タググループ:子](https://example.com)', '*区分*:',
|
||||
'[区分](https://example.com):'].each do |label|
|
||||
[:create, :update].each do |operation|
|
||||
it "rejects decorated #{ label } on #{ operation } without writes" do
|
||||
seed_group(name: '子', description: '子')
|
||||
id = seed_group(name: '更新', members:, placements:)
|
||||
expect_no_writes do
|
||||
if operation == :create
|
||||
post '/wiki', params: { title: 'タググループ:新規', body: "- #{ label }" },
|
||||
headers: headers
|
||||
else
|
||||
put_wiki(id, title: 'タググループ:更新', body: "- #{ label }")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
['# [区分](https://example.com)', '# *区分*', '# `区分`', '# 区分 {#custom}',
|
||||
'# '].each do |heading|
|
||||
it "rejects a decorated heading #{ heading } without writes" do
|
||||
expect_no_writes do
|
||||
post '/wiki', params: { title: 'タググループ:新規', body: "#{ heading }\n- 洗澡鹿" },
|
||||
headers: headers
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'preserves allowed markup in group and heading descriptions' do
|
||||
description = '[参照](https://example.com "リンクの説明") と *強調* と ``a`b``'
|
||||
body = "#{ description }\n\n# 区分\n#{ description }\n\n- 洗澡鹿"
|
||||
post '/wiki', params: { title: 'タググループ:説明構文', body: }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
version = group_version(id, 1)
|
||||
expect(version.fetch('description')).to eq(description)
|
||||
expect(version.fetch('placements').sole.fetch('description')).to eq(description)
|
||||
end
|
||||
end
|
||||
|
||||
context 'structure audit information' do
|
||||
let!(:child_id) { seed_group(name: '子', description: '子') }
|
||||
let(:members) do
|
||||
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
|
||||
end
|
||||
let(:placements) do
|
||||
[{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_group_id' => child_id, 'children' => [] }] }]
|
||||
end
|
||||
let!(:id) { seed_group(name: '更新', members:, placements:) }
|
||||
let!(:editor) { create(:user, :member) }
|
||||
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
|
||||
|
||||
it 'keeps all membership and placement audit fields on a name-only edit' do
|
||||
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||
travel_to(1.hour.from_now) do
|
||||
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
|
||||
expect(group_version(id, 2)).to include('name' => '改名', 'members' => members)
|
||||
end
|
||||
|
||||
it 'keeps membership and placement audit fields on a description-only edit' do
|
||||
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||
travel_to(1.hour.from_now) do
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload.merge(description: '説明を追加'), headers:, as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
|
||||
expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members)
|
||||
end
|
||||
|
||||
it 'rolls back membership removal and placement edits on a version persistence failure' do
|
||||
new_members = [{ 'tag_id' => tag.id }]
|
||||
new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }]
|
||||
expect_atomic_group_write('tag_group_versions') do
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
|
||||
end
|
||||
end
|
||||
|
||||
it 'changes only added or removed membership and keeps placement creation audit' do
|
||||
tag_row = TagGroupTag.find([id, tag.id]).attributes
|
||||
placement_row = TagGroupPlacement.find([id, 1]).attributes
|
||||
new_members = [{ 'tag_id' => tag.id }, { 'tag_id' => other_tag.id }]
|
||||
new_nodes = [{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_id' => other_tag.id, 'children' => [] }] }]
|
||||
child_row = TagGroupPlacement.find([id, 2]).attributes
|
||||
travel_to(1.hour.from_now) do
|
||||
put "/tag_groups/#{ id }",
|
||||
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroupTag.find([id, tag.id]).attributes).to eq(tag_row)
|
||||
expect(TagGroupGroup.exists?(tag_group_id: id, child_tag_group_id: child_id)).to be(false)
|
||||
expect(TagGroupTag.find([id, other_tag.id]).created_by_user_id).to eq(editor.id)
|
||||
expect(TagGroupPlacement.find([id, 1]).attributes).to eq(placement_row)
|
||||
changed = TagGroupPlacement.find([id, 2])
|
||||
expect(changed).to have_attributes(
|
||||
tag_id: other_tag.id, child_tag_group_id: nil, updated_by_user_id: editor.id,
|
||||
created_at: child_row.fetch('created_at'),
|
||||
created_by_user_id: child_row.fetch('created_by_user_id'))
|
||||
expect(changed.updated_at).to be > child_row.fetch('updated_at')
|
||||
expect(group_version(id, 1).fetch('members')).to eq(members)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,447 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group native API', type: :request do
|
||||
include TagGroupRecords
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:group_id) { seed_group(name: '専用API', description: '説明') }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def expect_native_group value
|
||||
expect(value).to include(
|
||||
'id' => group_id, 'name' => '専用API', 'description' => '説明',
|
||||
'version_no' => 1, 'members' => [], 'placements' => [],
|
||||
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
|
||||
current = group_rows('tag_groups', id: group_id).sole
|
||||
expect(value).to include(
|
||||
'created_at' => current.fetch('created_at').iso8601(3),
|
||||
'updated_at' => current.fetch('updated_at').iso8601(3))
|
||||
end
|
||||
|
||||
it 'serves the collection as JSON without reading Wiki tables' do
|
||||
without_group_legacy_reads { get '/tag_groups' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.media_type).to eq('application/json')
|
||||
expect(json).to be_a(Array)
|
||||
expect_native_group(json.find { _1.fetch('id') == group_id })
|
||||
end
|
||||
|
||||
it 'serves an independent group as JSON without reading Wiki tables' do
|
||||
without_group_legacy_reads { get "/tag_groups/#{ group_id }" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.media_type).to eq('application/json')
|
||||
expect_native_group(json)
|
||||
end
|
||||
|
||||
it 'provides the confirmed PUT update route' do
|
||||
route = Rails.application.routes.recognize_path("/tag_groups/#{ group_id }", method: :put)
|
||||
expect(route).to include(action: 'update', id: group_id.to_s)
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
[nil, :guest, :banned].each do |role|
|
||||
it "rejects #{ role || 'anonymous' } native #{ operation } without writes" do
|
||||
actor = case role
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
else
|
||||
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
|
||||
end
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects a banned IP for native #{ operation }" do
|
||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||
headers = { 'X-Transfer-Code' => user.inheritance_code }
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
else
|
||||
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
|
||||
end
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'structured membership and placements' do
|
||||
let!(:tag) { group_tag('掲載タグ') }
|
||||
let!(:unplaced) { group_tag('未掲載タグ') }
|
||||
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:members) do
|
||||
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
|
||||
end
|
||||
let(:snapshot_members) do
|
||||
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 },
|
||||
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }]
|
||||
end
|
||||
let(:placements) do
|
||||
[{ 'section_name' => '区分', 'description' => '区分の説明', 'children' => [
|
||||
{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_group_id' => child_id, 'children' => [] }] },
|
||||
{ 'tag_id' => tag.id, 'children' => [] }] }]
|
||||
end
|
||||
let(:payload) { { name: '構造化', description: '全体の説明', members:, placements: } }
|
||||
|
||||
def native_write operation, attributes = payload
|
||||
if operation == :create
|
||||
post '/tag_groups', params: attributes, headers:, as: :json
|
||||
else
|
||||
put "/tag_groups/#{ group_id }", params: attributes, headers:, as: :json
|
||||
end
|
||||
end
|
||||
|
||||
def expect_structured_response
|
||||
tag_repr = { 'id' => tag.id, 'name' => '掲載タグ', 'category' => tag.category }
|
||||
unplaced_repr = { 'id' => unplaced.id, 'name' => '未掲載タグ',
|
||||
'category' => unplaced.category }
|
||||
expect(json.fetch('members')).to contain_exactly(
|
||||
include('tag' => include(tag_repr)),
|
||||
include('tag' => include(unplaced_repr)),
|
||||
include('tag_group' => include('id' => child_id, 'name' => '子')))
|
||||
expect(json.fetch('placements')).to match([
|
||||
include('section_name' => '区分', 'description' => '区分の説明', 'children' => [
|
||||
include('tag' => include(tag_repr), 'children' => [
|
||||
include('tag_group' => include('id' => child_id, 'name' => '子'),
|
||||
'children' => [])]),
|
||||
include('tag' => include(tag_repr), 'children' => [])])])
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
it "#{ operation }s structured data and keeps unplaced membership" do
|
||||
previous = group_version(group_id, 1)
|
||||
native_write(operation)
|
||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
||||
id = operation == :create ? json.fetch('id') : group_id
|
||||
number = operation == :create ? 1 : 2
|
||||
expect_group_snapshot(
|
||||
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
||||
expect(group_version(group_id, 1)).to eq(previous)
|
||||
get "/tag_groups/#{ id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => id, 'name' => '構造化', 'description' => '全体の説明', 'version_no' => number,
|
||||
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
|
||||
expect_structured_response
|
||||
end
|
||||
|
||||
{
|
||||
'blank name' => { name: '' },
|
||||
'missing name' => { name: nil },
|
||||
'oversized name' => { name: '長' * 256 },
|
||||
'duplicate name' => { name: '子' },
|
||||
'placement without a kind' => { placements: [{ children: [] }] },
|
||||
'empty Section name' => { placements: [{ section_name: '', children: [] }] } }
|
||||
.each do |label, invalid|
|
||||
it "rejects #{ label } on #{ operation } without partial writes" do
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(invalid))
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects multiple placement kinds on #{ operation }" do
|
||||
before_state = group_state
|
||||
invalid = [{ tag_id: tag.id, section_name: '不正', children: [] }]
|
||||
native_write(operation, payload.merge(placements: invalid))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it "rejects a missing referenced Tag on #{ operation }" do
|
||||
missing = Tag.maximum(:id).to_i + 1
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(
|
||||
members: [{ tag_id: missing }], placements: [{ tag_id: missing, children: [] }]))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it "rejects a missing referenced group on #{ operation }" do
|
||||
missing = group_rows('tag_groups').map { _1.fetch('id') }.max + 1
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(
|
||||
members: [{ tag_group_id: missing }],
|
||||
placements: [{ tag_group_id: missing, children: [] }]))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it "rejects six descendant placement levels on #{ operation }" do
|
||||
nodes = []
|
||||
6.times { nodes = [{ tag_id: tag.id, children: nodes }] }
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(placements: nodes))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it 'preserves membership-only data in structured reads without inventing placements' do
|
||||
id = seed_group(name: '未掲載のみ', members: [snapshot_members.last])
|
||||
without_group_legacy_reads { get "/tag_groups/#{ id }" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('placements')).to eq([])
|
||||
expect(json.fetch('members')).to match([
|
||||
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
|
||||
end
|
||||
|
||||
it 'rejects indirect containment cycles' do
|
||||
parent = seed_group(
|
||||
name: '親', members: [{ 'tag_group_id' => group_id, 'version_no' => 1 }])
|
||||
before_state = group_state
|
||||
native_write(:update, payload.merge(
|
||||
members: [{ tag_group_id: parent }], placements: []))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'rejects direct containment even without a corresponding placement' do
|
||||
before_state = group_state
|
||||
native_write(:update, payload.merge(
|
||||
members: [{ tag_group_id: group_id }], placements: []))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'preserves list Section descriptions in native structured data' do
|
||||
nodes = [{ tag_id: tag.id, children: [
|
||||
{ section_name: 'リスト内区分', description: '消してはいけない説明', children: [] }] }]
|
||||
native_write(:update, payload.merge(placements: nodes))
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_version(group_id, 2).fetch('placements')).to eq(
|
||||
JSON.parse(nodes.to_json))
|
||||
get "/tag_groups/#{ group_id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
section = json.fetch('placements').sole.fetch('children').sole
|
||||
expect(section).to include(
|
||||
'section_name' => 'リスト内区分', 'description' => '消してはいけない説明')
|
||||
end
|
||||
|
||||
context 'compatibility writes for representable structures' do
|
||||
let(:group_id) do
|
||||
seed_group(name: '専用API', description: '説明')
|
||||
end
|
||||
let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] }
|
||||
let(:snapshot_members) do
|
||||
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
|
||||
end
|
||||
|
||||
it 'treats identical native content with unplaced membership as a no-op' do
|
||||
unplaced_payload = payload.merge(
|
||||
name: '専用API', members: members + [{ 'tag_id' => unplaced.id }])
|
||||
native_write(:update, unplaced_payload)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
group_id, number: 2, description: '全体の説明', placements:,
|
||||
members: snapshot_members + [
|
||||
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }])
|
||||
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:) do
|
||||
native_write(:update, unplaced_payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'no-op updates against synchronised state' do
|
||||
let(:synchronised_payload) do
|
||||
{ name: '構造化', description: '全体の説明',
|
||||
members: [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }], placements: }
|
||||
end
|
||||
let(:additional_tag) { group_tag('追加対象タグ') }
|
||||
|
||||
before do
|
||||
native_write(:update, synchronised_payload)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_markdown(TagGroup.find(group_id)))
|
||||
.to include('- 掲載タグ', '- タググループ:子')
|
||||
end
|
||||
|
||||
[1, 3].each do |repetitions|
|
||||
it "keeps identical native content unchanged after #{ repetitions } PUTs" do
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:) do
|
||||
repetitions.times do
|
||||
native_write(:update, synchronised_payload)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'treats membership order alone as a no-op' do
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:) do
|
||||
native_write(:update, synchronised_payload.merge(
|
||||
members: synchronised_payload.fetch(:members).reverse))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'keeps the original editor when another member submits identical content' do
|
||||
editor = create(:user, :member)
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:) do
|
||||
put "/tag_groups/#{ group_id }", params: synchronised_payload,
|
||||
headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not add history for Native content already changed through Wiki' do
|
||||
put "/wiki/#{ group_id }",
|
||||
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_markdown(TagGroup.find(group_id))).to eq('- 未掲載タグ')
|
||||
same_content = {
|
||||
name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }],
|
||||
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] }
|
||||
expect_group_snapshot(
|
||||
group_id, number: 3, description: nil,
|
||||
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
|
||||
placements: same_content.fetch(:placements))
|
||||
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:) do
|
||||
native_write(:update, same_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
['name', 'description', 'membership addition', 'membership removal',
|
||||
'Section name', 'Section description', 'placement order', 'placement hierarchy']
|
||||
.each do |change|
|
||||
it "records a real #{ change } change in native history" do
|
||||
changed = synchronised_payload.deep_dup
|
||||
section = changed.fetch(:placements).first
|
||||
children = section.fetch('children')
|
||||
case change
|
||||
when 'name' then changed[:name] = '実際の改名'
|
||||
when 'description' then changed[:description] = '実際の説明変更'
|
||||
when 'membership addition'
|
||||
expect(changed.fetch(:members)).not_to include('tag_id' => additional_tag.id)
|
||||
changed[:members] << { 'tag_id' => additional_tag.id }
|
||||
children << { 'tag_id' => additional_tag.id, 'children' => [] }
|
||||
when 'membership removal'
|
||||
changed[:members].reject! { _1.key?('tag_group_id') }
|
||||
children.first['children'] = []
|
||||
when 'Section name' then section['section_name'] = '別区分'
|
||||
when 'Section description' then section['description'] = '別の区分説明'
|
||||
when 'placement order' then children.reverse!
|
||||
when 'placement hierarchy'
|
||||
children << children.first.fetch('children').pop
|
||||
end
|
||||
|
||||
old_versions = group_rows('tag_group_versions', tag_group_id: group_id)
|
||||
group = TagGroup.find(group_id)
|
||||
number = group.version_no
|
||||
travel_to(1.hour.from_now) { native_write(:update, changed) }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expected_members = changed.fetch(:members).map do |member|
|
||||
target = if member.key?('tag_id')
|
||||
Tag.find(member.fetch('tag_id'))
|
||||
else
|
||||
TagGroup.find(member.fetch('tag_group_id'))
|
||||
end
|
||||
member.merge('version_no' => target.version_no)
|
||||
end
|
||||
expect_group_snapshot(
|
||||
group_id, number: number + 1, members: expected_members,
|
||||
placements: changed.fetch(:placements), description: changed.fetch(:description))
|
||||
expect(group.reload.name).to eq(changed.fetch(:name))
|
||||
versions = group_rows('tag_group_versions', tag_group_id: group_id)
|
||||
expect(versions.size).to eq(old_versions.size + 1)
|
||||
expect(versions.select { _1.fetch('version_no') <= number }).to eq(old_versions)
|
||||
get group_title_path(changed.fetch(:name))
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body')).to eq(group_markdown(group))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates native state and its native history' do
|
||||
expect { native_write(:create) }
|
||||
.to change(TagGroup, :count).by(1)
|
||||
.and change(TagGroupVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
expect_group_snapshot(
|
||||
json.fetch('id'), number: 1, members: snapshot_members,
|
||||
placements:, description: '全体の説明')
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
it "synchronises Wiki current state and histories on native #{ operation }" do
|
||||
native_write(operation)
|
||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
||||
id = operation == :create ? json.fetch('id') : group_id
|
||||
number = operation == :create ? 1 : 2
|
||||
expect_group_snapshot(
|
||||
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
||||
expect(TagGroup.find(id).versions.count).to eq(number)
|
||||
get group_title_path('構造化')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body')).to include('全体の説明', '# 区分', '- 掲載タグ')
|
||||
end
|
||||
|
||||
TagGroupRecords::GROUP_TABLES
|
||||
.each do |table|
|
||||
it "rolls back native #{ operation } after #{ table } writes" do
|
||||
expect_atomic_group_write(table) { native_write(operation) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'assigns the next group ID above the existing maximum' do
|
||||
child_id
|
||||
expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1
|
||||
native_write(:create)
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(json.fetch('id')).to be >= expected_id
|
||||
end
|
||||
|
||||
TagGroupRecords::GROUP_TABLES.each do |table|
|
||||
it "rolls back native creation after #{ table } writes" do
|
||||
expect_atomic_group_write(table) { native_write(:create) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'updates a native group through Wiki and then reads structured current data' do
|
||||
native_write(:create)
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
previous = group_version(id, 1)
|
||||
put "/wiki/#{ id }",
|
||||
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
id, number: 2,
|
||||
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
|
||||
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }])
|
||||
expect(group_version(id, 1)).to eq(previous)
|
||||
get "/tag_groups/#{ id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('version_no' => 2)
|
||||
expect(json.fetch('members')).to match([
|
||||
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag resource ID allocation', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
|
||||
def create_description name
|
||||
tag = create(:tag, primary_name: name)
|
||||
post '/wiki', params: { title: tag.name('ja'), body: 'description' }, headers: headers
|
||||
expect(response).to have_http_status(:created)
|
||||
TagDescription.find(json.fetch('id'))
|
||||
end
|
||||
|
||||
def create_group name
|
||||
post '/tag_groups', params: { name:, members: [], placements: [] }, headers:, as: :json
|
||||
expect(response).to have_http_status(:created)
|
||||
TagGroup.find(json.fetch('id'))
|
||||
end
|
||||
|
||||
it 'shares resource and revision IDs while keeping independent version numbers' do
|
||||
description = create_description('first')
|
||||
group = create_group('second')
|
||||
other = create_description('third')
|
||||
expect([description.id, group.id, other.id]).to eq(
|
||||
[description.id, group.id, other.id].sort.uniq)
|
||||
versions = [current_description_version(description), group.versions.sole,
|
||||
current_description_version(other)]
|
||||
expect(versions.map(&:id)).to eq(versions.map(&:id).sort.uniq)
|
||||
expect(versions.map(&:version_no)).to eq([1, 1, 1])
|
||||
[description, group, other].zip(versions).each do |resource, version|
|
||||
get "/wiki/#{ resource.id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('revision_id')).to eq(version.id)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not reuse a deleted description ID for a group or vice versa' do
|
||||
description = create_description('deleted')
|
||||
description.destroy!
|
||||
group = create_group('deleted group')
|
||||
expect(group.id).to be > description.id
|
||||
group.destroy!
|
||||
expect(create_description('replacement').id).to be > group.id
|
||||
end
|
||||
|
||||
it 'preserves resource and old revision identity on an update' do
|
||||
description = create_description('stable')
|
||||
original_resource_id = description.id
|
||||
original = current_description_version(description)
|
||||
snapshot = original.attributes
|
||||
put "/wiki/#{ description.id }",
|
||||
params: { title: 'stable', body: 'after', base_revision_id: original.id },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(description.reload.id).to eq(original_resource_id)
|
||||
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
|
||||
expect(original.reload.attributes).to eq(snapshot)
|
||||
expect(current_description_version(description).id).to be > original.id
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'TagVersions API', type: :request do
|
||||
include WikiRecords
|
||||
let(:member) { create(:user, :member, name: 'version member') }
|
||||
|
||||
let!(:tag) { create(:tag, primary_name: 'tag_versions_target', category: :general) }
|
||||
@@ -240,18 +241,19 @@ RSpec.describe 'TagVersions API', type: :request do
|
||||
wiki_tag = create(:tag, primary_tag_name: wiki_tag_name, category: :general)
|
||||
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: wiki_tag_name,
|
||||
body: 'before',
|
||||
created_by_user: member,
|
||||
message: 'init')
|
||||
|
||||
Wiki::Commit.content!(
|
||||
page: wiki_page,
|
||||
TagDescriptionOperation.update!(
|
||||
Locale.nipponese,
|
||||
tag: wiki_page.tag,
|
||||
body: 'after',
|
||||
created_user: member,
|
||||
updated_by_user: member,
|
||||
message: 'edit',
|
||||
base_revision_id: wiki_page.current_revision.id)
|
||||
base_revision_id: current_description_version(wiki_page).id)
|
||||
|
||||
get '/tags/versions', params: { id: wiki_tag.id }
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
include WikiRecords
|
||||
let(:member_user) { create(:user, role: 'member') }
|
||||
|
||||
def stub_current_user user
|
||||
@@ -13,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
end
|
||||
|
||||
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
@@ -28,7 +29,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
it 'renames the current Wiki resource without adding body history' do
|
||||
tag = create_tag!(name: 'patch_tag_wiki_before')
|
||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
||||
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||
|
||||
expect {
|
||||
patch "/tags/#{ tag.id }", params: {
|
||||
@@ -36,7 +37,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
@@ -44,16 +45,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
wiki_page.reload
|
||||
|
||||
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after')
|
||||
expect(wiki_page.title).to eq('patch_tag_wiki_after')
|
||||
expect(wiki_page.tag.name('ja')).to eq('patch_tag_wiki_after')
|
||||
|
||||
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
|
||||
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
|
||||
end
|
||||
|
||||
it 'does not record wiki_version when only category changes' do
|
||||
tag = create_tag!(name: 'patch_tag_category_only')
|
||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||
|
||||
before_wiki_versions = wiki_page.wiki_versions.count
|
||||
before_wiki_versions = description_versions(wiki_page).count
|
||||
|
||||
expect {
|
||||
patch "/tags/#{ tag.id }", params: {
|
||||
@@ -69,7 +70,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
|
||||
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_category_only')
|
||||
expect(tag.category).to eq('meme')
|
||||
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
|
||||
expect(description_versions(wiki_page).count).to eq(before_wiki_versions)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -77,7 +78,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
it 'renames the current Wiki resource without adding body history' do
|
||||
tag = create_tag!(name: 'put_tag_wiki_before')
|
||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
||||
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||
|
||||
expect {
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
@@ -89,7 +90,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
@@ -97,16 +98,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
wiki_page.reload
|
||||
|
||||
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after')
|
||||
expect(wiki_page.title).to eq('put_tag_wiki_after')
|
||||
expect(wiki_page.tag.name('ja')).to eq('put_tag_wiki_after')
|
||||
|
||||
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
|
||||
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
|
||||
end
|
||||
|
||||
it 'does not record wiki_version when only category changes' do
|
||||
tag = create_tag!(name: 'put_tag_category_only')
|
||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||
|
||||
before_wiki_versions = wiki_page.wiki_versions.count
|
||||
before_wiki_versions = description_versions(wiki_page).count
|
||||
|
||||
expect {
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
@@ -126,14 +127,14 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
|
||||
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_category_only')
|
||||
expect(tag.category).to eq('meme')
|
||||
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
|
||||
expect(description_versions(wiki_page).count).to eq(before_wiki_versions)
|
||||
end
|
||||
|
||||
it 'does not record wiki_version when only aliases change' do
|
||||
tag = create_tag!(name: 'put_tag_alias_only')
|
||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
||||
|
||||
before_wiki_versions = wiki_page.wiki_versions.count
|
||||
before_wiki_versions = description_versions(wiki_page).count
|
||||
|
||||
expect {
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
@@ -147,7 +148,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
.to change(TagVersion, :count).by(2)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions)
|
||||
expect(description_versions(wiki_page).count).to eq(before_wiki_versions)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ require 'cgi'
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tags API', type: :request do
|
||||
include WikiRecords
|
||||
let!(:tn) { create(:tag_name, name: 'spec_tag') }
|
||||
let!(:tag) { create(:tag, primary_tag_name: tn, category: :general) }
|
||||
let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', tag:) }
|
||||
@@ -38,6 +39,22 @@ RSpec.describe 'Tags API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /tags' do
|
||||
it 'serialises descriptions and parents without loading legacy Wiki pages' do
|
||||
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
|
||||
TagDescription.create!(tag: tag2, language_code: 'ja', body: '', version_no: 1)
|
||||
TagImplication.create!(tag:, parent_tag: tag2)
|
||||
queries = []
|
||||
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
get '/tags', params: { name: 'spec_tag' }
|
||||
end
|
||||
expect(response).to have_http_status(:ok)
|
||||
row = response_tags.sole
|
||||
expect(row.fetch('has_wiki')).to be(true)
|
||||
expect(row.fetch('parents').sole.fetch('has_wiki')).to be(true)
|
||||
expect(queries.grep(/\bSELECT\b.*\bwiki_(pages|revisions|versions)\b/im)).to be_empty
|
||||
end
|
||||
|
||||
it 'includes legacy external JSON alongside an internal tag with the same id' do
|
||||
external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3)
|
||||
|
||||
@@ -729,13 +746,13 @@ RSpec.describe 'Tags API', type: :request do
|
||||
|
||||
it 'PATCH の名称変更で Wiki の本文履歴を追加しない' do
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body: 'wiki body before',
|
||||
created_by_user: member_user,
|
||||
message: 'init')
|
||||
|
||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
||||
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||
|
||||
expect {
|
||||
patch "/tags/#{ tag.id }", params: {
|
||||
@@ -743,23 +760,23 @@ RSpec.describe 'Tags API', type: :request do
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
expect(wiki_page.reload.title).to eq('patch_wiki_renamed_tag')
|
||||
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
|
||||
expect(wiki_page.reload.tag.name('ja')).to eq('patch_wiki_renamed_tag')
|
||||
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
|
||||
end
|
||||
|
||||
it 'tag の category だけを変更しても wiki version は作成しない' do
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body: 'wiki body before',
|
||||
created_by_user: member_user,
|
||||
message: 'init')
|
||||
|
||||
before_wiki_version_count = wiki_page.reload.wiki_versions.count
|
||||
before_wiki_version_count = description_versions(wiki_page).count
|
||||
|
||||
expect {
|
||||
patch "/tags/#{ tag.id }", params: {
|
||||
@@ -768,7 +785,7 @@ RSpec.describe 'Tags API', type: :request do
|
||||
}.to change(TagVersion, :count).by(2)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
|
||||
expect(description_versions(wiki_page).count).to eq(before_wiki_version_count)
|
||||
end
|
||||
|
||||
it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do
|
||||
@@ -799,7 +816,7 @@ RSpec.describe 'Tags API', type: :request do
|
||||
|
||||
it '別 tag の正規名には変更できない' do
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body: 'patch collision wiki',
|
||||
created_by_user: member_user,
|
||||
@@ -815,7 +832,7 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag')
|
||||
expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name))
|
||||
.to contain_exactly('unko')
|
||||
expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja'))
|
||||
expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja'))
|
||||
end
|
||||
|
||||
it 'system tag の name は変更できない' do
|
||||
@@ -1193,7 +1210,7 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(old_name_alias).to have_attributes(
|
||||
tag_id: tag.id, language_code: 'ja', primary_flg: false)
|
||||
|
||||
expect(alias_tn.reload.tag_id).to be_nil
|
||||
expect(TagName.exists?(alias_tn.id)).to be(false)
|
||||
|
||||
expect(TagName.where(tag_id: tag.parents.select(:id),
|
||||
language_code: 'ja', primary_flg: true).pluck(:name))
|
||||
@@ -1378,20 +1395,17 @@ RSpec.describe 'Tags API', type: :request do
|
||||
|
||||
it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body: 'wiki body before',
|
||||
created_by_user: member_user,
|
||||
message: 'init')
|
||||
|
||||
Wiki::Commit.content!(
|
||||
page: wiki_page,
|
||||
body: 'wiki body before',
|
||||
created_user: member_user,
|
||||
message: 'init'
|
||||
)
|
||||
TagDescriptionOperation.update!(
|
||||
Locale.nipponese, tag: wiki_page.tag, body: 'wiki body before',
|
||||
updated_by_user: member_user, message: 'init')
|
||||
|
||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
||||
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||
|
||||
expect {
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
@@ -1403,22 +1417,22 @@ RSpec.describe 'Tags API', type: :request do
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
expect(wiki_page.reload.title).to eq('put_wiki_version_tag')
|
||||
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
|
||||
expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_version_tag')
|
||||
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
|
||||
end
|
||||
|
||||
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body: 'wiki body before',
|
||||
created_by_user: member_user,
|
||||
message: 'init')
|
||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
||||
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||
|
||||
expect {
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
@@ -1438,20 +1452,19 @@ RSpec.describe 'Tags API', type: :request do
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(3)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
tag.reload
|
||||
|
||||
expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja'))
|
||||
expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b').wiki_page)
|
||||
.to be_nil
|
||||
expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja'))
|
||||
expect(wiki_page.reload.tag_id).to eq(tag.id)
|
||||
expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b'))
|
||||
.to have_attributes(tag_id: tag.id, primary_flg: false)
|
||||
|
||||
expect(wiki_page.title).to eq('spec_tag')
|
||||
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
|
||||
expect(wiki_page.tag.name('ja')).to eq('spec_tag')
|
||||
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
|
||||
end
|
||||
|
||||
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
|
||||
@@ -1504,7 +1517,7 @@ RSpec.describe 'Tags API', type: :request do
|
||||
name: 'put_alias_collision_name',
|
||||
tag: old_owner)
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body: 'put collision wiki',
|
||||
created_by_user: member_user,
|
||||
@@ -1538,7 +1551,7 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(old_owner_versions.last.event_type).to eq('update')
|
||||
expect(version_alias_names(old_owner_versions.last, 'ja'))
|
||||
.not_to include('put_alias_collision_name')
|
||||
expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja'))
|
||||
expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja'))
|
||||
end
|
||||
|
||||
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
||||
@@ -1563,13 +1576,13 @@ RSpec.describe 'Tags API', type: :request do
|
||||
|
||||
it 'Tag の名称変更で Wiki の本文履歴を追加しない' do
|
||||
wiki_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_description(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body: 'wiki body before',
|
||||
created_by_user: member_user,
|
||||
message: 'init')
|
||||
|
||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
||||
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||
|
||||
expect {
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
@@ -1581,12 +1594,12 @@ RSpec.describe 'Tags API', type: :request do
|
||||
}
|
||||
}
|
||||
.to change(TagVersion, :count).by(2)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
expect(wiki_page.reload.title).to eq('put_wiki_renamed_tag')
|
||||
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
|
||||
expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_renamed_tag')
|
||||
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
@@ -9,34 +10,33 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
|
||||
it 'returns 409 when base_revision_id is stale' do
|
||||
page =
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: create(:tag_name, name: 'wiki_conflict_request'),
|
||||
seed_description(
|
||||
tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'),
|
||||
body: 'first',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
|
||||
stale_id = page.current_revision.id
|
||||
stale_id = current_description_version(page).id
|
||||
|
||||
Wiki::Commit.content!(
|
||||
page:,
|
||||
TagDescriptionOperation.update!(
|
||||
Locale.nipponese,
|
||||
tag: page.tag,
|
||||
body: 'second',
|
||||
created_user: user,
|
||||
updated_by_user: user,
|
||||
message: 'other edit',
|
||||
base_revision_id: stale_id)
|
||||
|
||||
put "/wiki/#{ page.id }",
|
||||
params: {
|
||||
title: 'wiki_conflict_request',
|
||||
body: 'third',
|
||||
message: 'stale',
|
||||
base_revision_id: stale_id,
|
||||
},
|
||||
params: { title: 'wiki_conflict_request',
|
||||
body: 'third',
|
||||
message: 'stale',
|
||||
base_revision_id: stale_id },
|
||||
headers: auth_headers(user)
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
|
||||
page.reload
|
||||
expect(page.body).to eq('second')
|
||||
expect(page.current_revision.message).to eq('other edit')
|
||||
expect(current_description_version(page)).to have_attributes(body: 'second', version_no: 2)
|
||||
end
|
||||
end
|
||||
|
||||
変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示
新しいイシューから参照
ユーザーをブロックする