コミットを比較
8
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
f362b915a5 | ||
|
|
6ba87beef6 | ||
|
|
47c40afcc1 | ||
|
|
1e464107a8 | ||
|
|
c142f08e6c | ||
|
|
2ef872bd33 | ||
|
|
620a0b5cff | ||
|
|
e44887cf15 |
@@ -70,7 +70,10 @@ class MaterialsController < ApplicationController
|
|||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
return head :not_found unless material
|
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,
|
render json: MaterialRepr.base(material, host: request.base_url,
|
||||||
language_code: locale.language_code).merge(wiki_page_body:)
|
language_code: locale.language_code).merge(wiki_page_body:)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class NicoTagsController < ApplicationController
|
|||||||
ExternalTag
|
ExternalTag
|
||||||
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
|
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
|
||||||
'ON post_tag_max.external_tag_id = external_tags.id')
|
'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
|
if name
|
||||||
q = q.where(('external_tags.name LIKE ? ' +
|
q = q.where(('external_tags.name LIKE ? ' +
|
||||||
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
|
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class PostsController < ApplicationController
|
|||||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
||||||
.preload(:external_tags, :uploaded_user, :parents, :children,
|
.preload(:external_tags, :uploaded_user, :parents, :children,
|
||||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||||
{ tag_names: :wiki_page }] }])
|
:tag_names, :tag_descriptions] }])
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
|
|
||||||
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
||||||
@@ -111,7 +111,7 @@ class PostsController < ApplicationController
|
|||||||
filtered_posts(locale)
|
filtered_posts(locale)
|
||||||
.preload(:uploaded_user, :parents, :children,
|
.preload(:uploaded_user, :parents, :children,
|
||||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||||
{ tag_names: :wiki_page }] }])
|
:tag_names, :tag_descriptions] }])
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
.order('RAND()')
|
.order('RAND()')
|
||||||
.first
|
.first
|
||||||
@@ -198,7 +198,7 @@ class PostsController < ApplicationController
|
|||||||
Post
|
Post
|
||||||
.includes(:uploaded_user, :parents, :children,
|
.includes(:uploaded_user, :parents, :children,
|
||||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||||
{ tag_names: :wiki_page }] }])
|
:tag_names, :tag_descriptions] }])
|
||||||
.with_attached_thumbnail
|
.with_attached_thumbnail
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
return head :not_found unless post
|
return head :not_found unless post
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
class TagDescriptionsController < ApplicationController
|
class TagDescriptionsController < ApplicationController
|
||||||
def show
|
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
|
return head :not_found unless description
|
||||||
|
|
||||||
render json: representation(description)
|
render json: representation(description)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show_by_title
|
def show_by_title
|
||||||
tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip)
|
locale = resolve_locale!
|
||||||
description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja')
|
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
|
return head :not_found unless description
|
||||||
|
|
||||||
render json: representation(description)
|
render json: representation(description)
|
||||||
@@ -21,18 +25,19 @@ class TagDescriptionsController < ApplicationController
|
|||||||
body = params[:body].to_s
|
body = params[:body].to_s
|
||||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
tag = Tag.find_by(id: params[:id])
|
tag = Tag.find_by(id: params[:id])
|
||||||
return head :not_found unless tag
|
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
|
if description
|
||||||
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user)
|
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
|
||||||
description.reload
|
description.reload
|
||||||
render json: representation(description)
|
render json: representation(description)
|
||||||
else
|
else
|
||||||
TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user)
|
TagDescriptionOperation.create!(locale, tag:, body:, created_by_user: current_user)
|
||||||
description = TagDescription.find_by!(tag:, language_code: 'ja')
|
description = TagDescription.find_by!(tag:, language_code: locale.language_code)
|
||||||
render json: representation(description), status: :created
|
render json: representation(description), status: :created
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
class TagGroupsController < ApplicationController
|
class TagGroupsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
|
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
|
||||||
render json: TagGroupRepr.many(groups.to_a)
|
render json: TagGroupRepr.many(locale, groups.to_a)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
locale = resolve_locale!
|
||||||
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
||||||
return head :not_found unless group
|
return head :not_found unless group
|
||||||
|
|
||||||
render json: TagGroupRepr.one(group)
|
render json: TagGroupRepr.one(locale, group)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
group = TagGroupOperation.create!(**write_attributes, actor: current_user)
|
locale = resolve_locale!
|
||||||
render json: TagGroupRepr.one(group), status: :created
|
group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
|
||||||
|
render json: TagGroupRepr.one(locale, group), status: :created
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
group = TagGroup.find_by(id: params[:id])
|
group = TagGroup.find_by(id: params[:id])
|
||||||
return head :not_found unless group
|
return head :not_found unless group
|
||||||
|
|
||||||
TagGroupOperation.update!(group:, **write_attributes, actor: current_user)
|
TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
|
||||||
render json: TagGroupRepr.one(group.reload)
|
render json: TagGroupRepr.one(locale, group.reload)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class TagVersionsController < ApplicationController
|
|||||||
|
|
||||||
tags_by_id =
|
tags_by_id =
|
||||||
Tag
|
Tag
|
||||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||||
.where(id: all_parent_tag_ids)
|
.where(id: all_parent_tag_ids)
|
||||||
.index_by(&:id)
|
.index_by(&:id)
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class TagsController < ApplicationController
|
|||||||
tags_by_id =
|
tags_by_id =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_names)
|
.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 },
|
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||||
id: tag_ids)
|
id: tag_ids)
|
||||||
.index_by(&:id)
|
.index_by(&:id)
|
||||||
@@ -238,7 +238,7 @@ class TagsController < ApplicationController
|
|||||||
base =
|
base =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_names)
|
.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 },
|
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||||
deprecated_at: nil)
|
deprecated_at: nil)
|
||||||
base = base.where('tags.post_count > 0') if present_only
|
base = base.where('tags.post_count > 0') if present_only
|
||||||
@@ -282,7 +282,7 @@ class TagsController < ApplicationController
|
|||||||
tag =
|
tag =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_names)
|
.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],
|
.find_by(id: params[:id],
|
||||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||||
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||||
@@ -302,7 +302,7 @@ class TagsController < ApplicationController
|
|||||||
tag =
|
tag =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_names)
|
.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 })
|
.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
|
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||||
|
|
||||||
@@ -320,7 +320,7 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
tag =
|
tag =
|
||||||
Tag.joins(:tag_names)
|
Tag.joins(:tag_names)
|
||||||
.includes(:tag_names, tag_names: :wiki_page)
|
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||||
.find_by(id: params[:id],
|
.find_by(id: params[:id],
|
||||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||||
return head :not_found unless tag
|
return head :not_found unless tag
|
||||||
@@ -340,7 +340,7 @@ class TagsController < ApplicationController
|
|||||||
tag =
|
tag =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_names)
|
.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 })
|
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||||
return head :not_found unless tag
|
return head :not_found unless tag
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ class TagsController < ApplicationController
|
|||||||
tag =
|
tag =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_names)
|
.joins(:tag_names)
|
||||||
.includes(:tag_names, tag_names: :wiki_page)
|
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||||
.find_by(id: params[:id],
|
.find_by(id: params[:id],
|
||||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||||
return head :not_found unless tag
|
return head :not_found unless tag
|
||||||
@@ -424,7 +424,7 @@ class TagsController < ApplicationController
|
|||||||
tag =
|
tag =
|
||||||
Tag
|
Tag
|
||||||
.joins(:tag_names)
|
.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 })
|
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||||
return head :not_found unless tag
|
return head :not_found unless tag
|
||||||
|
|
||||||
@@ -770,31 +770,15 @@ class TagsController < ApplicationController
|
|||||||
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id)
|
current_tag_name&.update!(primary_flg: false, auto_generated: false)
|
||||||
current_tag_name.association(:wiki_page).reset
|
promoted_tag_name.update!(tag:, primary_flg: true, auto_generated: false)
|
||||||
|
tag.association(:tag_names).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)
|
|
||||||
|
|
||||||
return unless old_owner_tag && old_owner_tag != tag
|
return unless old_owner_tag && old_owner_tag != tag
|
||||||
|
|
||||||
record_tag_version!(old_owner_tag.reload, event_type: :update,
|
record_tag_version!(old_owner_tag.reload, event_type: :update,
|
||||||
created_by_user: current_user,
|
created_by_user: current_user,
|
||||||
language_code: current_tag_name.language_code)
|
language_code: promoted_tag_name.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_aliases! locale, tag, alias_names
|
def update_aliases! locale, tag, alias_names
|
||||||
@@ -802,20 +786,15 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
affected_tags = [tag]
|
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,
|
current_aliases = tag.tag_names.where(language_code: locale.language_code,
|
||||||
primary_flg: false).to_a
|
primary_flg: false).to_a
|
||||||
|
|
||||||
current_aliases.each do |alias_tag_name|
|
requested_aliases = TagName.where(
|
||||||
next if alias_names.include?(alias_tag_name.name)
|
language_code: locale.language_code, name: alias_names)
|
||||||
|
.includes(:tag).index_by(&:name)
|
||||||
affected_tags << alias_tag_name.tag
|
|
||||||
end
|
|
||||||
|
|
||||||
alias_names.each do |alias_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
|
if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id
|
||||||
alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.')
|
alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.')
|
||||||
raise ActiveRecord::RecordInvalid, alias_tag_name
|
raise ActiveRecord::RecordInvalid, alias_tag_name
|
||||||
@@ -823,7 +802,8 @@ class TagsController < ApplicationController
|
|||||||
affected_tags << alias_tag_name&.tag
|
affected_tags << alias_tag_name&.tag
|
||||||
end
|
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,
|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user,
|
||||||
language_code: locale.language_code)
|
language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
@@ -831,19 +811,21 @@ class TagsController < ApplicationController
|
|||||||
current_aliases.each do |alias_tag_name|
|
current_aliases.each do |alias_tag_name|
|
||||||
next if alias_names.include?(alias_tag_name.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
|
end
|
||||||
|
|
||||||
alias_names.each do |alias_name|
|
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,
|
language_code: locale.language_code,
|
||||||
name: alias_name)
|
name: alias_name)
|
||||||
alias_tag_name.script_code = locale.script_code
|
alias_tag_name.script_code = locale.script_code
|
||||||
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false,
|
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false)
|
||||||
canonical_id: primary_tag_name.id)
|
|
||||||
end
|
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,
|
record_tag_version!(affected_tag, event_type: :update,
|
||||||
created_by_user: current_user,
|
created_by_user: current_user,
|
||||||
language_code: locale.language_code)
|
language_code: locale.language_code)
|
||||||
|
|||||||
@@ -1,104 +1,106 @@
|
|||||||
class WikiPagesController < ApplicationController
|
class WikiPagesController < ApplicationController
|
||||||
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
|
# 互換性のための Facade API
|
||||||
|
|
||||||
|
rescue_from TagDescriptionOperation::Conflict, with: :render_wiki_conflict
|
||||||
|
|
||||||
def index
|
def index
|
||||||
# 互換性のための化石リソース
|
locale = resolve_locale!
|
||||||
|
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
descriptions =
|
descriptions =
|
||||||
TagDescription.where(language_code: 'ja')
|
TagDescription
|
||||||
|
.where(language_code: locale.language_code)
|
||||||
.includes(tag: :tag_names)
|
.includes(tag: :tag_names)
|
||||||
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
|
|
||||||
|
|
||||||
tag_groups = TagGroup.all
|
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
||||||
|
tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
|
||||||
if title.present?
|
if title.present?
|
||||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||||
descriptions =
|
descriptions =
|
||||||
descriptions.joins(tag: :tag_names)
|
descriptions.joins(tag: :tag_names)
|
||||||
.where(tag_names: { language_code: 'ja', primary_flg: true })
|
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||||
.where('tag_names.name LIKE ?', pattern).limit(20)
|
.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||||
tag_groups = tag_groups.where(
|
tag_groups = tag_groups.where(
|
||||||
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
|
'CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?',
|
||||||
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
|
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
|
||||||
end
|
end
|
||||||
|
|
||||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
json = descriptions.map { |description| WikiPageRepr.tag_description(locale, description) }
|
||||||
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||||
json.concat(WikiPageRepr.base(pages))
|
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?
|
json = json.first(20) if title.present?
|
||||||
render json:
|
render json:
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
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
|
||||||
|
|
||||||
tag_group = TagGroup.find_by(id: params[:id])
|
tag_group = TagGroup.find_by(id: params[:id])
|
||||||
return render_tag_group tag_group if tag_group
|
return render_tag_group locale, tag_group if tag_group
|
||||||
|
|
||||||
page = WikiPage.joins(:tag_name)
|
head :not_found
|
||||||
.includes(tag_name: :tag)
|
|
||||||
.find_by(id: params[:id])
|
|
||||||
|
|
||||||
render_wiki_page_or_404 page
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show_by_title
|
def show_by_title
|
||||||
|
locale = resolve_locale!
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
|
|
||||||
if title.start_with?(TagGroupRepr::PREFIX)
|
if (name = TagGroupRepr.name_from_title(title))
|
||||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
|
||||||
tag_group = TagGroup.find_by(name:)
|
tag_group = TagGroup.find_by(name:)
|
||||||
return render_tag_group tag_group if tag_group
|
return render_tag_group locale, tag_group if tag_group
|
||||||
return head :not_found
|
return head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
tag_description = tag_description_by_title(title)
|
tag_description = tag_description_by_title(locale, title)
|
||||||
|
|
||||||
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 =
|
head :not_found
|
||||||
WikiPage
|
|
||||||
.joins(:tag_name)
|
|
||||||
.includes(tag_name: :tag)
|
|
||||||
.find_by(tag_name: { name: title })
|
|
||||||
|
|
||||||
render_wiki_page_or_404 page
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def exists
|
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])
|
return head :no_content if TagGroup.exists?(id: params[:id])
|
||||||
|
|
||||||
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
|
head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def exists_by_title
|
def exists_by_title
|
||||||
|
locale = resolve_locale!
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
if title.start_with?(TagGroupRepr::PREFIX)
|
if (name = TagGroupRepr.name_from_title(title))
|
||||||
exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX))
|
exists = TagGroup.exists?(name:)
|
||||||
return head exists ? :no_content : :not_found
|
return head exists ? :no_content : :not_found
|
||||||
end
|
end
|
||||||
return head :no_content if tag_description_by_title(title)
|
|
||||||
|
|
||||||
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
return head :no_content if tag_description_by_title(locale, title)
|
||||||
head exists ? :no_content : :not_found
|
|
||||||
|
head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def diff
|
def diff
|
||||||
id = params[:id]
|
id = params[:id]
|
||||||
return render_bad_request('id は必須です.') if id.blank?
|
return render_bad_request('id は必須です.') if id.blank?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
from = params[:from].presence
|
from = params[:from].presence
|
||||||
to = params[:to].presence
|
to = params[:to].presence
|
||||||
|
|
||||||
description = TagDescription.find_by(id:)
|
description = TagDescription.find_by(id:, language_code: locale.language_code)
|
||||||
if description
|
if description
|
||||||
versions = description_versions(description)
|
versions = description_versions(description)
|
||||||
from_rev = from && versions.find(from)
|
from_rev = from && versions.find(from)
|
||||||
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
|
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
|
else
|
||||||
tag_group = TagGroup.find_by(id:)
|
tag_group = TagGroup.find_by(id:)
|
||||||
if tag_group
|
if tag_group
|
||||||
@@ -106,21 +108,13 @@ class WikiPagesController < ApplicationController
|
|||||||
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
|
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
|
||||||
return head :not_found if (from && !from_rev) || !to_rev
|
return head :not_found if (from && !from_rev) || !to_rev
|
||||||
|
|
||||||
from_body = from_rev && render_group_version(from_rev)
|
from_body = from_rev && render_group_version(locale, from_rev)
|
||||||
to_body = render_group_version(to_rev)
|
to_body = render_group_version(locale, to_rev)
|
||||||
resource = { 'id' => tag_group.id,
|
resource = { 'id' => tag_group.id,
|
||||||
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||||
'deprecated_at' => nil }
|
'deprecated_at' => nil }
|
||||||
else
|
else
|
||||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
return head :not_found
|
||||||
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('差分を表示できない版です.')
|
|
||||||
end
|
|
||||||
from_body = from_rev&.body
|
|
||||||
to_body = to_rev.body
|
|
||||||
resource = WikiPageRepr.base(page)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -149,37 +143,31 @@ class WikiPagesController < ApplicationController
|
|||||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||||
|
|
||||||
if title.start_with?(TagGroupRepr::PREFIX)
|
locale = resolve_locale!
|
||||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
|
||||||
parsed = TagGroupMarkdownParser.parse(body:, group: nil)
|
if (name = TagGroupRepr.name_from_title(title))
|
||||||
group = TagGroupOperation.create!(name:, **parsed, actor: current_user,
|
parsed = TagGroupMarkdownParser.parse(locale, body:, group: nil)
|
||||||
require_compatibility: true)
|
group = TagGroupOperation.create!(
|
||||||
|
locale, name:, **parsed.except(:compatibility_body), actor: current_user,
|
||||||
|
require_compatibility: true)
|
||||||
version = current_group_version(group)
|
version = current_group_version(group)
|
||||||
return render_group_representation(TagGroupRepr.one(group), version,
|
return render_group_representation(TagGroupRepr.one(locale, group), version,
|
||||||
body: parsed.fetch(:compatibility_body),
|
|
||||||
status: :created)
|
status: :created)
|
||||||
end
|
end
|
||||||
|
|
||||||
page = if title.include?(':')
|
description =
|
||||||
ApplicationRecord.transaction do
|
if title.include?(':')
|
||||||
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
|
return render_unprocessable_entity field: :title
|
||||||
name.script_code = 'Jpan'
|
else
|
||||||
name.primary_flg = true
|
tag_name = TagName.find_by(language_code: locale.language_code, name: title)
|
||||||
name.auto_generated = false
|
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
|
end
|
||||||
|
|
||||||
Wiki::Commit.create_legacy_content!(
|
render json: WikiPageRepr.tag_description(locale, description), status: :created
|
||||||
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:)
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: WikiPageRepr.base(page), status: :created
|
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
render_validation_error e.record
|
render_validation_error e.record
|
||||||
rescue ActiveRecord::RecordNotUnique
|
rescue ActiveRecord::RecordNotUnique
|
||||||
@@ -198,55 +186,47 @@ class WikiPagesController < ApplicationController
|
|||||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||||
|
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
group = TagGroup.find_by(id: params[:id])
|
group = TagGroup.find_by(id: params[:id])
|
||||||
if title.start_with?(TagGroupRepr::PREFIX)
|
if (name = TagGroupRepr.name_from_title(title))
|
||||||
return head :not_found unless group
|
return head :not_found unless group
|
||||||
|
|
||||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
parsed = TagGroupMarkdownParser.parse(locale, body:, group:)
|
||||||
parsed = TagGroupMarkdownParser.parse(body:, group:)
|
TagGroupOperation.update!(
|
||||||
TagGroupOperation.update!(group:, name:, **parsed, actor: current_user,
|
locale, group:, name:, **parsed.except(:compatibility_body), actor: current_user,
|
||||||
require_compatibility: true)
|
require_compatibility: true)
|
||||||
return head :ok
|
return head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
page = WikiPage.find_by(id: params[:id])
|
return render_unprocessable_entity field: :title if title.include?(':')
|
||||||
if legacy_group_page?(page)
|
description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
|
||||||
return render_unprocessable_entity field: :title
|
unless description
|
||||||
|
return render_unprocessable_entity field: :title if group
|
||||||
|
return head :not_found
|
||||||
end
|
end
|
||||||
if group && !legacy_update_identified?(page, title, group)
|
|
||||||
return render_unprocessable_entity field: :title
|
tag = description.tag
|
||||||
end
|
tag_name = tag.tag_name(locale.language_code)
|
||||||
return head :not_found unless page
|
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
|
base_revision_id = params[:base_revision_id].presence
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
page.lock!
|
tag.lock!
|
||||||
|
old_title = tag_name.name
|
||||||
old_title = page.title
|
if title != old_title
|
||||||
|
|
||||||
tag = page.tag_name.tag
|
|
||||||
|
|
||||||
if tag && title != old_title
|
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
page.tag_name.update!(name: title) if title != old_title
|
tag_name.update!(name: title) if title != old_title
|
||||||
|
|
||||||
message = params[:message].presence
|
message = params[:message].presence
|
||||||
description = TagDescription.find_by(id: page.id)
|
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
|
||||||
description_page = description && (
|
message:, base_revision_id:)
|
||||||
tag&.id == description.tag_id ||
|
|
||||||
(tag.nil? && !old_title.include?(':')))
|
|
||||||
if description && !description_page
|
|
||||||
Wiki::Commit.legacy_content!(page:, body:, created_user: current_user,
|
|
||||||
message:, base_revision_id:)
|
|
||||||
else
|
|
||||||
Wiki::Commit.content!(page:, body:, created_user: current_user,
|
|
||||||
message:, base_revision_id:)
|
|
||||||
end
|
|
||||||
|
|
||||||
if tag && title != old_title
|
if title != old_title
|
||||||
tag.reload
|
tag.reload
|
||||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
@@ -262,10 +242,12 @@ class WikiPagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def changes
|
def changes
|
||||||
|
locale = resolve_locale!
|
||||||
|
|
||||||
id = params[:id].presence
|
id = params[:id].presence
|
||||||
description = id && TagDescription.find_by(id:)
|
description = id && TagDescription.find_by(id:, language_code: locale.language_code)
|
||||||
if description
|
if description
|
||||||
return render json: description_changes(TagDescription.where(id: description.id))
|
return render json: description_changes(locale, TagDescription.where(id: description.id))
|
||||||
end
|
end
|
||||||
|
|
||||||
tag_group = id && TagGroup.find_by(id:)
|
tag_group = id && TagGroup.find_by(id:)
|
||||||
@@ -273,26 +255,11 @@ class WikiPagesController < ApplicationController
|
|||||||
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
||||||
end
|
end
|
||||||
|
|
||||||
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
|
json = []
|
||||||
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 }
|
|
||||||
}
|
|
||||||
unless id
|
unless id
|
||||||
json.concat(tag_group_changes(TagGroupVersion.all))
|
json.concat(tag_group_changes(TagGroupVersion.all))
|
||||||
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
json.concat(description_changes(locale,
|
||||||
|
TagDescription.where(language_code: locale.language_code)))
|
||||||
json = json.each_with_index.sort_by do |row, index|
|
json = json.each_with_index.sort_by do |row, index|
|
||||||
[-row.fetch(:timestamp).to_f, index]
|
[-row.fetch(:timestamp).to_f, index]
|
||||||
end.map(&:first)
|
end.map(&:first)
|
||||||
@@ -302,27 +269,7 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def legacy_group_page? page
|
def render_tag_group locale, group
|
||||||
return false unless page
|
|
||||||
return true if page.title.start_with?(TagGroupRepr::PREFIX)
|
|
||||||
|
|
||||||
prefix = ApplicationRecord.sanitize_sql_like(TagGroupRepr::PREFIX)
|
|
||||||
page.wiki_versions.where('title LIKE ?', "#{ prefix }%").exists?
|
|
||||||
end
|
|
||||||
|
|
||||||
def legacy_update_identified? page, title, group
|
|
||||||
return false unless page
|
|
||||||
return true if page.title == title
|
|
||||||
return false if title == group.name
|
|
||||||
|
|
||||||
namespace = page.title.split(':', 2)
|
|
||||||
return true if namespace.size == 2 && title.start_with?("#{ namespace.first }:")
|
|
||||||
|
|
||||||
base = params[:base_revision_id].presence
|
|
||||||
base && page.wiki_revisions.exists?(id: base)
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_tag_group group
|
|
||||||
version = if params[:version].present?
|
version = if params[:version].present?
|
||||||
group_version(group, params[:version])
|
group_version(group, params[:version])
|
||||||
else
|
else
|
||||||
@@ -330,16 +277,17 @@ class WikiPagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
return head :not_found unless version
|
return head :not_found unless version
|
||||||
|
|
||||||
representation = if params[:version].present?
|
representation =
|
||||||
TagGroupRepr.historical(version)
|
if params[:version].present?
|
||||||
else
|
TagGroupRepr.historical(locale, version)
|
||||||
TagGroupRepr.one(group)
|
else
|
||||||
end
|
TagGroupRepr.one(locale, group)
|
||||||
|
end
|
||||||
render_group_representation(representation, version)
|
render_group_representation(representation, version)
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_group_version version
|
def render_group_version locale, version
|
||||||
representation = TagGroupRepr.historical(version)
|
representation = TagGroupRepr.historical(locale, version)
|
||||||
TagGroupMarkdownRenderer.render(representation)
|
TagGroupMarkdownRenderer.render(representation)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -351,6 +299,7 @@ class WikiPagesController < ApplicationController
|
|||||||
updated_at = version.created_at
|
updated_at = version.created_at
|
||||||
render json: {
|
render json: {
|
||||||
'id' => version.tag_group_id,
|
'id' => version.tag_group_id,
|
||||||
|
'resource_type' => 'tag_group',
|
||||||
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
||||||
'deprecated_at' => nil,
|
'deprecated_at' => nil,
|
||||||
'body' => body,
|
'body' => body,
|
||||||
@@ -407,28 +356,7 @@ class WikiPagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_wiki_page_or_404 page
|
def render_wiki_page_by_tag_description locale, description
|
||||||
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)
|
|
||||||
end
|
|
||||||
|
|
||||||
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:)
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_wiki_page_by_tag_description description
|
|
||||||
versions = description_versions(description)
|
versions = description_versions(description)
|
||||||
historical = params[:version].present?
|
historical = params[:version].present?
|
||||||
rev = historical ? versions.find_by(id: params[:version]) :
|
rev = historical ? versions.find_by(id: params[:version]) :
|
||||||
@@ -439,7 +367,7 @@ class WikiPagesController < ApplicationController
|
|||||||
succ = versions.find_by(version_no: rev.version_no + 1)&.id
|
succ = versions.find_by(version_no: rev.version_no + 1)&.id
|
||||||
body = historical ? rev.body : description.body
|
body = historical ? rev.body : description.body
|
||||||
updated_at = historical ? rev.created_at : description.updated_at
|
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:)
|
body:, revision_id: rev.id, pred:, succ:, updated_at:)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -448,23 +376,14 @@ class WikiPagesController < ApplicationController
|
|||||||
language_code: description.language_code)
|
language_code: description.language_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_description_by_title title
|
def tag_description_by_title locale, title
|
||||||
TagDescription.joins(tag: :tag_names)
|
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
|
end
|
||||||
|
|
||||||
def unmigrated_wiki_pages
|
def description_changes locale, descriptions
|
||||||
descriptions = TagDescription.where(language_code: 'ja')
|
versions = TagDescriptionVersion.where(language_code: locale.language_code,
|
||||||
|
|
||||||
WikiPage
|
|
||||||
.joins(:tag_name)
|
|
||||||
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
|
|
||||||
.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',
|
|
||||||
tag_id: descriptions.select(:tag_id))
|
tag_id: descriptions.select(:tag_id))
|
||||||
.order(id: :desc).limit(200).to_a
|
.order(id: :desc).limit(200).to_a
|
||||||
return [] if versions.empty?
|
return [] if versions.empty?
|
||||||
@@ -473,7 +392,7 @@ class WikiPagesController < ApplicationController
|
|||||||
.includes(tag: :tag_names).index_by(&:tag_id)
|
.includes(tag: :tag_names).index_by(&:tag_id)
|
||||||
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
|
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
|
||||||
predecessors = TagDescriptionVersion.where(
|
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 })
|
version_no: versions.map { |version| version.version_no - 1 })
|
||||||
.pluck(:tag_id, :version_no, :id)
|
.pluck(:tag_id, :version_no, :id)
|
||||||
.to_h { |tag_id, version_no, id| [[tag_id, version_no], id] }
|
.to_h { |tag_id, version_no, id| [[tag_id, version_no], id] }
|
||||||
@@ -483,7 +402,7 @@ class WikiPagesController < ApplicationController
|
|||||||
{ revision_id: version.id,
|
{ revision_id: version.id,
|
||||||
pred: predecessors[[version.tag_id, version.version_no - 1]],
|
pred: predecessors[[version.tag_id, version.version_no - 1]],
|
||||||
succ: nil,
|
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 },
|
deprecated_at: description.tag.deprecated_at },
|
||||||
user: user && { id: user.id, name: user.name },
|
user: user && { id: user.id, name: user.name },
|
||||||
kind: 'content',
|
kind: 'content',
|
||||||
@@ -492,14 +411,6 @@ class WikiPagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
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
|
def render_wiki_conflict err
|
||||||
render json: { error: 'conflict', message: err.message }, status: :conflict
|
render json: { error: 'conflict', message: err.message }, status: :conflict
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class ExternalTag < ApplicationRecord
|
class ExternalTag < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
enum :platform, nico: 'nico'
|
enum :platform, nico: 'nico'
|
||||||
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ class Locale < ApplicationRecord
|
|||||||
def generate_tag_names!
|
def generate_tag_names!
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
|
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
|
||||||
Tag.where.not(id: tag_ids).find_each do
|
Tag.where.not(id: tag_ids).includes(:tag_names).find_each do
|
||||||
TagName.create!(tag_id: _1.id,
|
TagName.create!(tag_id: _1.id,
|
||||||
language_code:,
|
language_code:,
|
||||||
name: TagName.generate_name(self, _1, _1.tag_name('ja')),
|
name: TagName.generate_name(self, _1, _1.tag_name('ja')),
|
||||||
script_code:,
|
script_code:,
|
||||||
primary_flg: true,
|
primary_flg: true,
|
||||||
auto_generated: true,
|
auto_generated: true)
|
||||||
canonical_id: nil)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+24
-22
@@ -2,6 +2,8 @@ require 'set'
|
|||||||
|
|
||||||
|
|
||||||
class Tag < ApplicationRecord
|
class Tag < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
class NicoTagNormalisationError < ArgumentError
|
class NicoTagNormalisationError < ArgumentError
|
||||||
;
|
;
|
||||||
end
|
end
|
||||||
@@ -51,6 +53,7 @@ class Tag < ApplicationRecord
|
|||||||
has_many :tag_versions
|
has_many :tag_versions
|
||||||
|
|
||||||
has_many :tag_names
|
has_many :tag_names
|
||||||
|
has_many :tag_descriptions
|
||||||
|
|
||||||
attribute :version_no, :integer, default: 1
|
attribute :version_no, :integer, default: 1
|
||||||
|
|
||||||
@@ -88,7 +91,6 @@ class Tag < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
def name(language_code) = tag_name(language_code)&.name
|
def name(language_code) = tag_name(language_code)&.name
|
||||||
def wiki_page = tag_name('ja')&.wiki_page
|
|
||||||
|
|
||||||
def system?
|
def system?
|
||||||
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
|
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
|
||||||
@@ -96,7 +98,13 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
def deprecated? = deprecated_at?
|
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 material_id = materials.first&.id
|
||||||
|
|
||||||
@@ -251,16 +259,13 @@ class Tag < ApplicationRecord
|
|||||||
name = name.to_s.strip
|
name = name.to_s.strip
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
tn = TagName.find_or_create_by!(language_code:, name:) do
|
tn = TagName.find_by(language_code:, name:)
|
||||||
_1.script_code = locale.script_code
|
next tn.tag if tn
|
||||||
_1.primary_flg = true
|
|
||||||
end
|
|
||||||
|
|
||||||
tag = tn.tag
|
tag = Tag.create!(category:)
|
||||||
next tag if tag
|
tn = TagName.create!(tag:, language_code:, name:,
|
||||||
|
script_code: locale.script_code, primary_flg: true,
|
||||||
tag = Tag.create!(tag_name_id: tn.id, category:)
|
auto_generated: false)
|
||||||
tn.update!(tag:, primary_flg: true, canonical_id: nil, auto_generated: false)
|
|
||||||
generate_tag_names!(locale, tag, tn)
|
generate_tag_names!(locale, tag, tn)
|
||||||
tag.association(:tag_names).reset
|
tag.association(:tag_names).reset
|
||||||
|
|
||||||
@@ -294,20 +299,18 @@ class Tag < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
source_tag_name = source_tag.tag_name('ja')
|
if source_tag.tag_descriptions.exists?
|
||||||
|
source_tag.errors.add(:base, :invalid)
|
||||||
if source_tag_name.wiki_page.present?
|
raise ActiveRecord::RecordInvalid, source_tag
|
||||||
raise ActiveRecord::RecordInvalid.new(source_tag_name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
|
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
|
||||||
source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false,
|
target_languages = target_tag.tag_names.where(primary_flg: true).pluck(:language_code)
|
||||||
updated_at: Time.current)
|
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.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:)
|
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -367,8 +370,7 @@ class Tag < ApplicationRecord
|
|||||||
name: TagName.generate_name(locale, tag, base_tag_name),
|
name: TagName.generate_name(locale, tag, base_tag_name),
|
||||||
script_code:,
|
script_code:,
|
||||||
primary_flg: true,
|
primary_flg: true,
|
||||||
auto_generated: true,
|
auto_generated: true)
|
||||||
canonical_id: nil)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
class TagDescription < ApplicationRecord
|
class TagDescription < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
belongs_to :tag
|
belongs_to :tag
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
class TagDescriptionVersion < ApplicationRecord
|
class TagDescriptionVersion < ApplicationRecord
|
||||||
;
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
class TagGroup < ApplicationRecord
|
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 :created_by_user, class_name: 'User', optional: true
|
||||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
@@ -9,8 +12,16 @@ class TagGroup < ApplicationRecord
|
|||||||
has_many :placements, -> { order(:position) },
|
has_many :placements, -> { order(:position) },
|
||||||
class_name: 'TagGroupPlacement', dependent: :delete_all
|
class_name: 'TagGroupPlacement', dependent: :delete_all
|
||||||
has_many :versions, -> { order(:version_no) },
|
has_many :versions, -> { order(:version_no) },
|
||||||
class_name: 'TagGroupVersion', dependent: :delete_all
|
class_name: 'TagGroupVersion'
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
|
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
|
||||||
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
|
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
|
end
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
class TagGroupVersion < ApplicationRecord
|
class TagGroupVersion < ApplicationRecord
|
||||||
include VersionRecord
|
include VersionRecord
|
||||||
|
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
belongs_to :tag_group
|
belongs_to :tag_group
|
||||||
|
|
||||||
scope :ordered, -> { order(:version_no) }
|
scope :ordered, -> { order(:version_no) }
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
class TagName < ApplicationRecord
|
class TagName < ApplicationRecord
|
||||||
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
||||||
belongs_to :tag, optional: true
|
belongs_to :tag
|
||||||
has_one :wiki_page
|
|
||||||
|
|
||||||
validates :name, presence: true,
|
validates :name, presence: true,
|
||||||
length: { maximum: 255 },
|
length: { maximum: 255 },
|
||||||
uniqueness: { scope: :language_code }
|
uniqueness: { scope: :language_code }
|
||||||
|
|
||||||
validate :alias_name_must_not_have_prefix
|
validate :alias_name_must_not_have_prefix
|
||||||
validate :alias_must_not_have_wiki_page
|
|
||||||
validate :name_must_be_sanitised
|
validate :name_must_be_sanitised
|
||||||
validate :name_mustnt_start_with_nico
|
validate :name_mustnt_start_with_nico
|
||||||
validate :generated_name_must_be_reserved
|
validate :generated_name_must_be_reserved
|
||||||
@@ -21,14 +19,16 @@ class TagName < ApplicationRecord
|
|||||||
|
|
||||||
tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name)
|
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|
|
names.map { |name|
|
||||||
tag_name = tns[name]
|
tag_name = tns[name]
|
||||||
if !tag_name || tag_name.primary? || tag_name.tag_id.nil?
|
if !tag_name || tag_name.primary?
|
||||||
name
|
name
|
||||||
else
|
else
|
||||||
TagName.find_by(language_code: locale.language_code,
|
primary_names[tag_name.tag_id] || name
|
||||||
tag_id: tag_name.tag_id,
|
|
||||||
primary_flg: true)&.name || name
|
|
||||||
end
|
end
|
||||||
}.uniq
|
}.uniq
|
||||||
end
|
end
|
||||||
@@ -53,12 +53,6 @@ class TagName < ApplicationRecord
|
|||||||
end
|
end
|
||||||
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
|
def name_must_be_sanitised
|
||||||
if name? && name != TagNameSanitisationRule.sanitise(name)
|
if name? && name != TagNameSanitisationRule.sanitise(name)
|
||||||
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
||||||
|
|||||||
@@ -16,37 +16,36 @@ class TagNameSanitisationRule < ApplicationRecord
|
|||||||
|
|
||||||
def apply!
|
def apply!
|
||||||
Language.find_each do |language|
|
Language.find_each do |language|
|
||||||
TagName.where(language:).find_each do |tn|
|
TagName.where(language:).find_each do |candidate|
|
||||||
name = sanitise(tn.name)
|
|
||||||
next if name == tn.name
|
|
||||||
|
|
||||||
TagName.transaction do
|
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:)
|
existing_tn = TagName.find_by(language:, name:)
|
||||||
if existing_tn
|
source_tag = tn.tag
|
||||||
unless existing_tn.primary_flg
|
target_tag = existing_tn ? existing_tn.tag : source_tag
|
||||||
existing_tn = TagName.find_by!(language:,
|
[source_tag, target_tag].uniq(&:id).sort_by(&:id).each(&:lock!)
|
||||||
tag_id: existing_tn.tag_id,
|
TagVersioning.ensure_snapshot!(source_tag, created_by_user: nil)
|
||||||
primary_flg: true)
|
|
||||||
|
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
|
end
|
||||||
next if existing_tn.id == tn.id
|
else
|
||||||
|
# TagName 側の自動サニタイズを回避
|
||||||
existing_tag = existing_tn.tag
|
tn.update_columns(name:, updated_at: Time.current)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# TagName 側の自動サニタイズを回避
|
TagVersioning.record!(target_tag, event_type: :update, created_by_user: nil)
|
||||||
tn.update_columns(name:, updated_at: Time.current)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ class User < ApplicationRecord
|
|||||||
has_many :ip_addresses, through: :user_ips
|
has_many :ip_addresses, through: :user_ips
|
||||||
has_many :user_post_views, dependent: :destroy
|
has_many :user_post_views, dependent: :destroy
|
||||||
has_many :viewed_posts, through: :user_post_views, source: :post
|
has_many :viewed_posts, through: :user_post_views, source: :post
|
||||||
has_many :created_wiki_pages,
|
has_many :created_tag_descriptions, class_name: 'TagDescription',
|
||||||
class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify
|
foreign_key: :created_by_user_id, dependent: :nullify
|
||||||
has_many :updated_wiki_pages,
|
has_many :updated_tag_descriptions, class_name: 'TagDescription',
|
||||||
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
|
foreign_key: :updated_by_user_id, dependent: :nullify
|
||||||
|
|
||||||
def viewed?(post) = user_post_views.exists?(post_id: post.id)
|
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
|
|
||||||
@@ -3,7 +3,12 @@ module TagGroupRepr
|
|||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def many groups
|
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)
|
ids = groups.map(&:id)
|
||||||
return [] if ids.empty?
|
return [] if ids.empty?
|
||||||
|
|
||||||
@@ -14,14 +19,20 @@ module TagGroupRepr
|
|||||||
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
|
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
|
||||||
.group_by(&:tag_group_id)
|
.group_by(&:tag_group_id)
|
||||||
|
|
||||||
target_ids = tag_members.values.flatten.map(&:tag_id) +
|
target_ids =
|
||||||
group_members.values.flatten.map(&:child_tag_group_id) +
|
tag_members.values.flatten.map(&:tag_id) +
|
||||||
placements.values.flatten.filter_map(&:tag_id)
|
placements.values.flatten.filter_map(&:tag_id)
|
||||||
child_ids = group_members.values.flatten.map(&:child_tag_group_id) +
|
child_ids =
|
||||||
|
group_members.values.flatten.map(&:child_tag_group_id) +
|
||||||
placements.values.flatten.filter_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)
|
tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id)
|
||||||
names = TagName.where(tag_id: target_ids.uniq, language_code: 'ja', primary_flg: true)
|
names =
|
||||||
.pluck(:tag_id, :name).to_h
|
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)
|
child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id)
|
||||||
|
|
||||||
groups.map do |group|
|
groups.map do |group|
|
||||||
@@ -32,8 +43,8 @@ module TagGroupRepr
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def one group
|
def one locale, group
|
||||||
many([group]).first
|
many(locale, [group]).first
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_group wiki_page
|
def tag_group wiki_page
|
||||||
@@ -48,7 +59,7 @@ module TagGroupRepr
|
|||||||
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||||
end
|
end
|
||||||
|
|
||||||
def historical version
|
def historical locale, version
|
||||||
members = Array(version.members)
|
members = Array(version.members)
|
||||||
placements = Array(version.placements)
|
placements = Array(version.placements)
|
||||||
tag_references = members.filter_map { |member| member['tag_id'] } +
|
tag_references = members.filter_map { |member| member['tag_id'] } +
|
||||||
@@ -68,10 +79,10 @@ module TagGroupRepr
|
|||||||
[member.fetch('tag_group_id'), member.fetch('version_no')]
|
[member.fetch('tag_group_id'), member.fetch('version_no')]
|
||||||
end.to_h
|
end.to_h
|
||||||
historical_members = members.map do |member|
|
historical_members = members.map do |member|
|
||||||
historical_member(member, tag_versions, group_versions)
|
historical_member(locale, member, tag_versions, group_versions)
|
||||||
end
|
end
|
||||||
historical_placements = placements.map do |node|
|
historical_placements = placements.map do |node|
|
||||||
historical_placement(node, tag_versions, group_versions,
|
historical_placement(locale, node, tag_versions, group_versions,
|
||||||
tag_version_numbers, group_version_numbers)
|
tag_version_numbers, group_version_numbers)
|
||||||
end
|
end
|
||||||
{ 'id' => version.tag_group_id,
|
{ 'id' => version.tag_group_id,
|
||||||
@@ -89,7 +100,7 @@ module TagGroupRepr
|
|||||||
def group_json group, tag_members, group_members, rows, tags, names, child_groups
|
def group_json group, tag_members, group_members, rows, tags, names, child_groups
|
||||||
tree = placement_tree(rows, tags, names, child_groups)
|
tree = placement_tree(rows, tags, names, child_groups)
|
||||||
members = tag_members.sort_by(&:tag_id).map do |member|
|
members = tag_members.sort_by(&:tag_id).map do |member|
|
||||||
{ 'tag' => tag_json(tags.fetch(member.tag_id), names.fetch(member.tag_id)) }
|
{ 'tag' => tag_json(tags.fetch(member.tag_id), names[member.tag_id]) }
|
||||||
end
|
end
|
||||||
members.concat(group_members.sort_by(&:child_tag_group_id).map do |member|
|
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)) }
|
{ 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) }
|
||||||
@@ -115,7 +126,7 @@ module TagGroupRepr
|
|||||||
build = lambda do |parent|
|
build = lambda do |parent|
|
||||||
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
|
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
|
||||||
node = if row.tag_id
|
node = if row.tag_id
|
||||||
{ 'tag' => tag_json(tags.fetch(row.tag_id), names.fetch(row.tag_id)) }
|
{ 'tag' => tag_json(tags.fetch(row.tag_id), names[row.tag_id]) }
|
||||||
elsif row.child_tag_group_id
|
elsif row.child_tag_group_id
|
||||||
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
|
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
|
||||||
else
|
else
|
||||||
@@ -153,11 +164,11 @@ module TagGroupRepr
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def historical_member member, tag_versions, group_versions
|
def historical_member locale, member, tag_versions, group_versions
|
||||||
if member['tag_id']
|
if member['tag_id']
|
||||||
id = member.fetch('tag_id')
|
id = member.fetch('tag_id')
|
||||||
version = tag_versions.fetch([id, member.fetch('version_no')])
|
version = tag_versions.fetch([id, member.fetch('version_no')])
|
||||||
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
|
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
|
||||||
'category' => version.category } }
|
'category' => version.category } }
|
||||||
else
|
else
|
||||||
id = member.fetch('tag_group_id')
|
id = member.fetch('tag_group_id')
|
||||||
@@ -166,13 +177,13 @@ module TagGroupRepr
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def historical_placement(node, tag_versions, group_versions,
|
def historical_placement locale, node, tag_versions, group_versions,
|
||||||
tag_version_numbers, group_version_numbers)
|
tag_version_numbers, group_version_numbers
|
||||||
result = if node['tag_id']
|
result = if node['tag_id']
|
||||||
id = node.fetch('tag_id')
|
id = node.fetch('tag_id')
|
||||||
version_no = tag_version_numbers.fetch(id)
|
version_no = tag_version_numbers.fetch(id)
|
||||||
version = tag_versions.fetch([id, version_no])
|
version = tag_versions.fetch([id, version_no])
|
||||||
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
|
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
|
||||||
'category' => version.category } }
|
'category' => version.category } }
|
||||||
elsif node['tag_group_id']
|
elsif node['tag_group_id']
|
||||||
id = node.fetch('tag_group_id')
|
id = node.fetch('tag_group_id')
|
||||||
@@ -183,12 +194,12 @@ module TagGroupRepr
|
|||||||
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
|
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
|
||||||
end
|
end
|
||||||
result.merge('children' => Array(node['children']).map do |child|
|
result.merge('children' => Array(node['children']).map do |child|
|
||||||
historical_placement(child, tag_versions, group_versions,
|
historical_placement(locale, child, tag_versions, group_versions,
|
||||||
tag_version_numbers, group_version_numbers)
|
tag_version_numbers, group_version_numbers)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def japanese_version_name version
|
def version_name locale, version
|
||||||
version.names.fetch('ja').fetch('name')
|
version.names[locale.language_code]&.fetch('name')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,21 +3,28 @@
|
|||||||
|
|
||||||
module TagRepr
|
module TagRepr
|
||||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
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
|
module_function
|
||||||
|
|
||||||
def base tag, language_code: 'ja'
|
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),
|
aliases: tag.snapshot_aliases(language_code),
|
||||||
parents: tag.parents.map { inline(_1, language_code:) })
|
parents: tag.parents.map { inline(_1, language_code:) })
|
||||||
end
|
end
|
||||||
|
|
||||||
def inline tag, language_code: 'ja'
|
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
|
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
|
def name_for tag, language_code
|
||||||
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
|
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
|
||||||
|
|||||||
@@ -2,18 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
module WikiPageRepr
|
module WikiPageRepr
|
||||||
BASE = { methods: [:title, :deprecated_at] }.freeze
|
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def base wiki_page
|
def tag_description locale, description
|
||||||
wiki_page.as_json(BASE)
|
|
||||||
end
|
|
||||||
|
|
||||||
def tag_description description
|
|
||||||
tag = description.tag
|
tag = description.tag
|
||||||
|
|
||||||
{ 'id' => description.id,
|
{ 'id' => description.id,
|
||||||
'tag_name_id' => tag.tag_name('ja')&.id,
|
'resource_type' => 'tag_description',
|
||||||
|
'tag_name_id' => tag.tag_name(locale.language_code)&.id,
|
||||||
'body' => description.body,
|
'body' => description.body,
|
||||||
'created_user_id' => description.created_by_user_id,
|
'created_user_id' => description.created_by_user_id,
|
||||||
'updated_user_id' => description.updated_by_user_id,
|
'updated_user_id' => description.updated_by_user_id,
|
||||||
@@ -21,15 +17,11 @@ module WikiPageRepr
|
|||||||
'updated_at' => description.updated_at,
|
'updated_at' => description.updated_at,
|
||||||
'discarded_at' => nil,
|
'discarded_at' => nil,
|
||||||
'version_no' => description.version_no,
|
'version_no' => description.version_no,
|
||||||
'title' => tag.name('ja'),
|
'title' => tag.name(locale.language_code),
|
||||||
'deprecated_at' => tag.deprecated_at }
|
'deprecated_at' => tag.deprecated_at }
|
||||||
end
|
end
|
||||||
|
|
||||||
def tag_group tag_group
|
def tag_group tag_group
|
||||||
TagGroupRepr.tag_group(tag_group)
|
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||||
end
|
|
||||||
|
|
||||||
def many wiki_pages
|
|
||||||
wiki_pages.map { |p| base(p) }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -95,14 +95,10 @@ class PostCreatePlan
|
|||||||
def build_default_tag_specs locale, direct_tag_specs
|
def build_default_tag_specs locale, direct_tag_specs
|
||||||
default_tag_specs = []
|
default_tag_specs = []
|
||||||
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
|
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
|
||||||
default_tag_specs << {
|
default_tag_specs << { name: TAGME_TAG_NAME, category: :meta }
|
||||||
name: TAGME_TAG_NAME,
|
|
||||||
category: :meta }
|
|
||||||
end
|
end
|
||||||
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) }
|
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) }
|
||||||
default_tag_specs << {
|
default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta }
|
||||||
name: NO_DEERJIKIST_TAG_NAME,
|
|
||||||
category: :meta }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
default_tag_specs
|
default_tag_specs
|
||||||
@@ -117,9 +113,9 @@ class PostCreatePlan
|
|||||||
end
|
end
|
||||||
|
|
||||||
def validate_new_tag_spec! locale, spec
|
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 = 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?
|
return if tag_name.valid? && tag.valid?
|
||||||
|
|
||||||
post = Post.new
|
post = Post.new
|
||||||
@@ -140,10 +136,10 @@ class PostCreatePlan
|
|||||||
end
|
end
|
||||||
expanded_parent_specs =
|
expanded_parent_specs =
|
||||||
Tag.expand_parent_tags(existing_snapshot_tags)
|
Tag.expand_parent_tags(existing_snapshot_tags)
|
||||||
.reject(&:deprecated?)
|
.reject(&:deprecated?)
|
||||||
.map { |tag|
|
.map { |tag|
|
||||||
{ name: tag.name(locale.language_code),
|
{ name: tag.name(locale.language_code),
|
||||||
category: tag.category.to_sym }
|
category: tag.category.to_sym }
|
||||||
}
|
}
|
||||||
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
|
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
|
||||||
end
|
end
|
||||||
@@ -164,9 +160,10 @@ class PostCreatePlan
|
|||||||
def existing_tags_by_name locale
|
def existing_tags_by_name locale
|
||||||
@existing_tags_by_name ||= begin
|
@existing_tags_by_name ||= begin
|
||||||
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
|
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
|
||||||
TagName.where(language_code: locale.language_code, primary_flg: true, name: names)
|
TagName
|
||||||
.includes(:tag)
|
.where(language_code: locale.language_code, primary_flg: true, name: names)
|
||||||
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
|
.includes(:tag)
|
||||||
|
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
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 }"
|
||||||
|
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
|
||||||
|
|
||||||
|
release = lambda do
|
||||||
|
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
|
||||||
|
|
||||||
|
# 明示-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.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,67 @@
|
|||||||
class TagDescriptionOperation
|
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
|
ApplicationRecord.transaction do
|
||||||
tag.lock!
|
tag.lock!
|
||||||
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
|
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!(
|
description = TagDescription.new(tag:, language_code: locale.language_code)
|
||||||
tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:)
|
body = normalise_body!(description, body)
|
||||||
revision = page.wiki_revisions.order(id: :desc).first
|
record_version!(description:, body:, user: created_by_user)
|
||||||
record_version!(tag:, revision:, body:, user: created_by_user,
|
|
||||||
description: nil)
|
description
|
||||||
page
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.update!(tag:, body:, updated_by_user:, message: nil,
|
def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
|
||||||
base_revision_id: nil)
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
tag.lock!
|
tag.lock!
|
||||||
description = TagDescription.lock.find_by!(
|
description = TagDescription.lock.find_by!(
|
||||||
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
tag_id: tag.id, language_code: locale.language_code)
|
||||||
page = WikiPage.lock.find(description.id)
|
body = normalise_body!(description, body)
|
||||||
revision = Wiki::CompatibilityWriter.update!(
|
latest = TagDescriptionVersion.find_by(
|
||||||
page:, body:, updated_by_user:, message:,
|
tag_id: tag.id, language_code: locale.language_code,
|
||||||
base_revision_id:)
|
version_no: description.version_no)
|
||||||
next unless revision
|
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,
|
record_version!(description:, body:, user: updated_by_user)
|
||||||
user: updated_by_user, description:)
|
|
||||||
revision
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.record_version! tag:, revision:, body:, user:, description:
|
def self.record_version! description:, body:, user:
|
||||||
version_no = description ? description.version_no + 1 : 1
|
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!(
|
TagDescriptionVersion.create!(
|
||||||
id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE,
|
tag_id: description.tag_id, language_code: description.language_code,
|
||||||
version_no:, event_type: description ? 'update' : 'create', body:,
|
version_no:, event_type: creating ? 'create' : 'update', body:,
|
||||||
created_by_user_id: user.id, created_at: revision.created_at)
|
source_language_code: description.source_language_code,
|
||||||
if description
|
source_version_no: description.source_version_no,
|
||||||
description.update!(body:, version_no:, updated_by_user_id: user.id)
|
created_by_user_id: user.id, created_at: Time.current)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private_class_method :record_version!
|
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
|
end
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ class TagGroupMarkdownParser
|
|||||||
;
|
;
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.parse(body:, group: nil)
|
def self.parse(locale, body:, group: nil)
|
||||||
new(body:, group:).parse
|
new(body:, group:).parse(locale)
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(body:, group:)
|
def initialize(body:, group:)
|
||||||
@@ -16,7 +16,7 @@ class TagGroupMarkdownParser
|
|||||||
@group = group
|
@group = group
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse
|
def parse locale
|
||||||
parse_body = @body.gsub(/([^\n])\n([\x23]{1,6}[ \t]+\S)/, "\\1\n\n\\2")
|
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
|
root = Kramdown::Document.new(parse_body, input: 'GFM', auto_ids: false).root
|
||||||
placements = []
|
placements = []
|
||||||
@@ -51,7 +51,7 @@ class TagGroupMarkdownParser
|
|||||||
when :ul
|
when :ul
|
||||||
invalid!('未対応のリスト項目があります.') if node.attr.any?
|
invalid!('未対応のリスト項目があります.') if node.attr.any?
|
||||||
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
|
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
|
||||||
target.concat(node.children.map { parse_list_item(_1) })
|
target.concat(node.children.map { parse_list_item(locale, _1) })
|
||||||
else
|
else
|
||||||
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
|
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
|
||||||
end
|
end
|
||||||
@@ -66,7 +66,7 @@ class TagGroupMarkdownParser
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def parse_list_item(item)
|
def parse_list_item locale, item
|
||||||
invalid!('リスト項目ではありません.') unless item.type == :li
|
invalid!('リスト項目ではありません.') unless item.type == :li
|
||||||
invalid!('未対応のリスト項目があります.') if item.attr.any?
|
invalid!('未対応のリスト項目があります.') if item.attr.any?
|
||||||
|
|
||||||
@@ -81,29 +81,30 @@ class TagGroupMarkdownParser
|
|||||||
label = own.map { inline_text(_1) }.join(' ').strip
|
label = own.map { inline_text(_1) }.join(' ').strip
|
||||||
invalid!('空のリスト項目は使用できません.') if label.blank?
|
invalid!('空のリスト項目は使用できません.') if label.blank?
|
||||||
|
|
||||||
node = if label.start_with?(PREFIX)
|
node =
|
||||||
group_name = label.delete_prefix(PREFIX).strip
|
if (group_name = TagGroupRepr.name_from_title(label))
|
||||||
child_group = TagGroup.find_by(name: group_name)
|
group_name = group_name.strip
|
||||||
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
|
child_group = TagGroup.find_by(name: group_name)
|
||||||
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
|
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
|
||||||
{ 'tag_group_id' => child_group.id }
|
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
|
||||||
elsif label.end_with?(':', ':')
|
{ 'tag_group_id' => child_group.id }
|
||||||
section_name = label.sub(/[::]\z/, '').strip
|
elsif label.end_with?(':', ':')
|
||||||
invalid!('Section 名は必須です.') if section_name.blank?
|
section_name = label.sub(/[::]\z/, '').strip
|
||||||
{ 'section_name' => section_name, 'description' => nil }
|
invalid!('Section 名は必須です.') if section_name.blank?
|
||||||
else
|
{ 'section_name' => section_name, 'description' => nil }
|
||||||
{ 'tag_id' => resolve_tag(label) }
|
else
|
||||||
end
|
{ 'tag_id' => resolve_tag(locale, label) }
|
||||||
|
end
|
||||||
|
|
||||||
node['children'] = nested.flat_map do |list|
|
node['children'] = nested.flat_map do |list|
|
||||||
invalid!('未対応の入れ子リストです.') unless list.type == :ul
|
invalid!('未対応の入れ子リストです.') unless list.type == :ul
|
||||||
list.children.map { parse_list_item(_1) }
|
list.children.map { parse_list_item(locale, _1) }
|
||||||
end
|
end
|
||||||
node
|
node
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_tag(name)
|
def resolve_tag locale, name
|
||||||
names = TagName.where(language_code: 'ja', name:).to_a
|
names = TagName.where(language_code: locale.language_code, name:).to_a
|
||||||
tag_ids = names.filter_map(&:tag_id).uniq
|
tag_ids = names.filter_map(&:tag_id).uniq
|
||||||
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
|
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
|
||||||
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
|
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
|
||||||
|
|||||||
@@ -19,15 +19,20 @@ class TagGroupMarkdownRenderer
|
|||||||
blocks << clean_text(@group['description'])
|
blocks << clean_text(@group['description'])
|
||||||
end
|
end
|
||||||
|
|
||||||
sections = placements.select { _1.key?('section_name') }
|
first_section = placements.index { _1.key?('section_name') }
|
||||||
if sections.any? && sections.size != placements.size
|
|
||||||
raise UnsupportedStructure,
|
|
||||||
'最上位の見出し Section と Placement の混在は Markdown にできません.'
|
|
||||||
end
|
|
||||||
|
|
||||||
if placements.all? { _1.key?('section_name') }
|
if first_section
|
||||||
blocks.concat(placements.map { render_heading(_1, 2) })
|
if placements.drop(first_section).any? { !_1.key?('section_name') }
|
||||||
else
|
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)
|
blocks << render_list(placements, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -74,7 +79,7 @@ class TagGroupMarkdownRenderer
|
|||||||
first_section = section_indexes.first
|
first_section = section_indexes.first
|
||||||
if children[first_section..].any? { !_1.key?('section_name') }
|
if children[first_section..].any? { !_1.key?('section_name') }
|
||||||
raise UnsupportedStructure,
|
raise UnsupportedStructure,
|
||||||
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
|
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
|
||||||
end
|
end
|
||||||
list_nodes = children[0...first_section]
|
list_nodes = children[0...first_section]
|
||||||
blocks << render_list(list_nodes, 0) if list_nodes.any?
|
blocks << render_list(list_nodes, 0) if list_nodes.any?
|
||||||
|
|||||||
@@ -1,49 +1,51 @@
|
|||||||
class TagGroupOperation
|
class TagGroupOperation
|
||||||
MAX_PLACEMENT_DEPTH = 5
|
MAX_PLACEMENT_DEPTH = 5
|
||||||
PREFIX = TagGroupRepr::PREFIX
|
|
||||||
|
|
||||||
def self.create!(name:, description:, members:, placements:, actor:,
|
def self.create!(locale, name:, description:, members:, placements:, actor:,
|
||||||
require_compatibility: false, compatibility_body: nil)
|
require_compatibility: false)
|
||||||
group, sync_issue = ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
group = TagGroup.new(version_no: 1, created_by_user: actor,
|
group = TagGroup.new(version_no: 1, created_by_user: actor,
|
||||||
updated_by_user: actor)
|
updated_by_user: actor)
|
||||||
attributes = prepare!(group:, name:, description:, members:, placements:)
|
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
|
||||||
group.assign_attributes(name: attributes.fetch(:name),
|
group.assign_attributes(name: attributes.fetch(:name),
|
||||||
description: attributes.fetch(:description))
|
description: attributes.fetch(:description))
|
||||||
group.save!
|
group.save!
|
||||||
persist_structure!(group:, attributes:, actor:)
|
persist_structure!(group:, attributes:, actor:)
|
||||||
|
validate_compatibility!(locale, group) if require_compatibility
|
||||||
persist_version!(group:, attributes:, actor:, event_type: 'create')
|
persist_version!(group:, attributes:, actor:, event_type: 'create')
|
||||||
issue = sync_compatibility!(group:, actor:, old_name: nil,
|
group
|
||||||
require_compatibility:, compatibility_body:)
|
|
||||||
[group, issue]
|
|
||||||
end
|
end
|
||||||
report_sync_issue(group, actor, sync_issue)
|
|
||||||
group
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.update!(group:, name:, description:, members:, placements:, actor:,
|
def self.update!(locale, group:, name:, description:, members:, placements:, actor:,
|
||||||
require_compatibility: false, compatibility_body: nil)
|
require_compatibility: false)
|
||||||
group, sync_issue = ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
group.lock!
|
group.lock!
|
||||||
old_name = group.name
|
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
|
||||||
attributes = prepare!(group:, name:, description:, members:, placements:)
|
if unchanged?(group, attributes)
|
||||||
next [group, nil] if unchanged?(group, attributes)
|
validate_compatibility!(locale, group) if require_compatibility
|
||||||
|
next group
|
||||||
|
end
|
||||||
|
|
||||||
group.update!(name: attributes.fetch(:name),
|
group.update!(name: attributes.fetch(:name),
|
||||||
description: attributes.fetch(:description),
|
description: attributes.fetch(:description),
|
||||||
version_no: group.version_no + 1,
|
version_no: group.version_no + 1,
|
||||||
updated_by_user: actor)
|
updated_by_user: actor)
|
||||||
persist_structure!(group:, attributes:, actor:)
|
persist_structure!(group:, attributes:, actor:)
|
||||||
|
validate_compatibility!(locale, group) if require_compatibility
|
||||||
persist_version!(group:, attributes:, actor:, event_type: 'update')
|
persist_version!(group:, attributes:, actor:, event_type: 'update')
|
||||||
issue = sync_compatibility!(group:, actor:, old_name:,
|
group
|
||||||
require_compatibility:, compatibility_body:)
|
|
||||||
[group, issue]
|
|
||||||
end
|
end
|
||||||
report_sync_issue(group, actor, sync_issue)
|
|
||||||
group
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.prepare!(group:, name:, description:, members:, placements:)
|
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
|
record = group
|
||||||
normalized_name = name.to_s.strip
|
normalized_name = name.to_s.strip
|
||||||
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
|
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
|
||||||
@@ -61,7 +63,7 @@ class TagGroupOperation
|
|||||||
|
|
||||||
member_rows = normalize_members(record, members)
|
member_rows = normalize_members(record, members)
|
||||||
placement_tree = normalize_placements(record, placements)
|
placement_tree = normalize_placements(record, placements)
|
||||||
validate_references!(record, member_rows, placement_tree)
|
validate_references!(locale, record, member_rows, placement_tree)
|
||||||
validate_containment!(record, member_rows)
|
validate_containment!(record, member_rows)
|
||||||
{ name: normalized_name, description:, members: member_rows,
|
{ name: normalized_name, description:, members: member_rows,
|
||||||
placements: placement_tree }
|
placements: placement_tree }
|
||||||
@@ -185,7 +187,7 @@ class TagGroupOperation
|
|||||||
end
|
end
|
||||||
private_class_method :normalize_placement_nodes
|
private_class_method :normalize_placement_nodes
|
||||||
|
|
||||||
def self.validate_references!(record, members, placements)
|
def self.validate_references!(locale, record, members, placements)
|
||||||
membership = members.to_set
|
membership = members.to_set
|
||||||
placed = placement_references(placements).uniq
|
placed = placement_references(placements).uniq
|
||||||
missing = placed - membership.to_a
|
missing = placed - membership.to_a
|
||||||
@@ -204,13 +206,14 @@ class TagGroupOperation
|
|||||||
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
|
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
|
||||||
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
|
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
|
||||||
.pluck(:tag_group_id, :version_no).to_set
|
.pluck(:tag_group_id, :version_no).to_set
|
||||||
tag_names = TagName.where(tag_id: tag_ids, language_code: 'ja', primary_flg: true)
|
tag_names = TagName.where(
|
||||||
|
tag_id: tag_ids, language_code: locale.language_code, primary_flg: true)
|
||||||
.pluck(:tag_id).uniq
|
.pluck(:tag_id).uniq
|
||||||
|
|
||||||
members.each do |member|
|
members.each do |member|
|
||||||
if member.key?('tag_id')
|
if member.key?('tag_id')
|
||||||
id = member.fetch('tag_id')
|
id = member.fetch('tag_id')
|
||||||
invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") unless
|
invalid!(record, :members, :invalid) unless
|
||||||
tag_names.include?(id)
|
tag_names.include?(id)
|
||||||
version_no = tags.fetch(id).version_no
|
version_no = tags.fetch(id).version_no
|
||||||
unless tag_versions.include?([id, version_no])
|
unless tag_versions.include?([id, version_no])
|
||||||
@@ -313,57 +316,6 @@ class TagGroupOperation
|
|||||||
end
|
end
|
||||||
private_class_method :persist_version!
|
private_class_method :persist_version!
|
||||||
|
|
||||||
def self.sync_compatibility!(group:, actor:, old_name:,
|
|
||||||
require_compatibility:, compatibility_body:)
|
|
||||||
representation = TagGroupRepr.one(group)
|
|
||||||
body = begin
|
|
||||||
TagGroupMarkdownRenderer.render(representation)
|
|
||||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
|
||||||
if compatibility_body.present?
|
|
||||||
compatibility_body
|
|
||||||
elsif require_compatibility
|
|
||||||
invalid!(group, :base, error.message)
|
|
||||||
else
|
|
||||||
return error.message
|
|
||||||
end
|
|
||||||
end
|
|
||||||
invalid!(group, :base, '本文は必須です.') if body.blank?
|
|
||||||
|
|
||||||
title = "#{ PREFIX }#{ group.name }"
|
|
||||||
old_title = old_name && "#{ PREFIX }#{ old_name }"
|
|
||||||
page = if old_title
|
|
||||||
WikiPage.joins(:tag_name).find_by(tag_names: { name: old_title })
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
page ||= WikiPage.joins(:tag_name).find_by(tag_names: { name: title })
|
|
||||||
|
|
||||||
if page
|
|
||||||
tag_name = page.tag_name
|
|
||||||
tag_name.update!(name: title) if tag_name.name != title
|
|
||||||
Wiki::Commit.legacy_content!(page:, body:, created_user: actor, message: nil,
|
|
||||||
base_revision_id: nil)
|
|
||||||
else
|
|
||||||
tag_name = TagName.find_or_initialize_by(language_code: 'ja', name: title)
|
|
||||||
tag_name.script_code = 'Jpan'
|
|
||||||
tag_name.primary_flg = true
|
|
||||||
tag_name.auto_generated = false
|
|
||||||
tag_name.save!
|
|
||||||
Wiki::Commit.create_legacy_content!(tag_name:, body:, created_by_user: actor)
|
|
||||||
end
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
private_class_method :sync_compatibility!
|
|
||||||
|
|
||||||
def self.report_sync_issue(group, actor, reason)
|
|
||||||
return unless reason
|
|
||||||
|
|
||||||
payload = { status: 'unsupported_structure', tag_group_id: group.id,
|
|
||||||
version_no: group.version_no, user_id: actor&.id, reason: }
|
|
||||||
Rails.logger.warn("tag_group_wiki_sync_skipped #{ payload.to_json }")
|
|
||||||
end
|
|
||||||
private_class_method :report_sync_issue
|
|
||||||
|
|
||||||
def self.placement_references(nodes)
|
def self.placement_references(nodes)
|
||||||
nodes.flat_map do |node|
|
nodes.flat_map do |node|
|
||||||
own = if node.key?('tag_id')
|
own = if node.key?('tag_id')
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
生成ファイル
+2
-89
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) 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|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -578,17 +578,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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 "language_code", limit: 16, null: false
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "script_code", limit: 4, null: false
|
t.string "script_code", limit: 4, null: false
|
||||||
t.boolean "primary_flg", null: false
|
t.boolean "primary_flg", null: false
|
||||||
t.boolean "auto_generated", default: false, null: false
|
t.boolean "auto_generated", default: false, null: false
|
||||||
t.bigint "canonical_id"
|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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.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 ["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 ["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"
|
t.index ["script_code"], name: "fk_rails_dd783b3d1c"
|
||||||
@@ -625,7 +623,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
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.string "category", default: "general", null: false
|
||||||
t.datetime "deprecated_at"
|
t.datetime "deprecated_at"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -633,7 +630,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
|||||||
t.integer "post_count", default: 0, null: false
|
t.integer "post_count", default: 0, null: false
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
|
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 "(`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"
|
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
|
||||||
end
|
end
|
||||||
@@ -773,76 +769,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
|||||||
t.index ["banned_at"], name: "index_users_on_banned_at"
|
t.index ["banned_at"], name: "index_users_on_banned_at"
|
||||||
end
|
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_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "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"
|
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
|
||||||
@@ -915,12 +841,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
|||||||
add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
|
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", "languages", column: "language_code", primary_key: "code"
|
||||||
add_foreign_key "tag_names", "scripts", column: "script_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_names", "tags"
|
||||||
add_foreign_key "tag_similarities", "tags"
|
add_foreign_key "tag_similarities", "tags"
|
||||||
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
|
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
|
||||||
add_foreign_key "tag_versions", "users", column: "created_by_user_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", "theatres"
|
||||||
add_foreign_key "theatre_comments", "users"
|
add_foreign_key "theatre_comments", "users"
|
||||||
add_foreign_key "theatre_programmes", "posts"
|
add_foreign_key "theatre_programmes", "posts"
|
||||||
@@ -945,15 +869,4 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
|||||||
add_foreign_key "user_post_views", "posts"
|
add_foreign_key "user_post_views", "posts"
|
||||||
add_foreign_key "user_post_views", "users"
|
add_foreign_key "user_post_views", "users"
|
||||||
add_foreign_key "user_theme_slots", "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
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe 'Tag group migration identity and structure' do
|
|
||||||
include TagGroupRecords
|
|
||||||
|
|
||||||
let!(:user) { create_member_user! }
|
|
||||||
|
|
||||||
before { I18n.locale = :ja }
|
|
||||||
|
|
||||||
it 'preserves page and revision IDs, timestamps and repeated historical structures' do
|
|
||||||
tag = group_tag('洗澡鹿')
|
|
||||||
body = "説明文\n\n- 洗澡鹿\n- 洗澡鹿"
|
|
||||||
page = legacy_group('伊地知ニジカ亜種', body)
|
|
||||||
revisions = [page.current_revision]
|
|
||||||
[body, '# 空の区分', body].each do |text|
|
|
||||||
revisions << Wiki::Commit.legacy_content!(page:, body: text, created_user: user)
|
|
||||||
end
|
|
||||||
before_state = group_state.except(*TagGroupRecords::GROUP_TABLES)
|
|
||||||
|
|
||||||
migrate_groups
|
|
||||||
|
|
||||||
versions = group_rows('tag_group_versions', tag_group_id: page.id)
|
|
||||||
.sort_by { _1.fetch('version_no') }
|
|
||||||
expect(versions.map { _1.fetch('id') }).to eq(revisions.map(&:id))
|
|
||||||
expect(versions.map { _1.fetch('version_no') }).to eq([1, 2, 3, 4])
|
|
||||||
expect(versions.map { _1.fetch('created_at') }).to eq(revisions.map(&:created_at))
|
|
||||||
expect(versions.map { _1.fetch('created_by_user_id') }).to eq([user.id] * 4)
|
|
||||||
expect(versions.map { _1.fetch('event_type') }).to eq(['create', 'update', 'update', 'update'])
|
|
||||||
expect(group_version(page.id, 1).fetch('placements')).to eq(
|
|
||||||
group_version(page.id, 2).fetch('placements'))
|
|
||||||
expect(group_version(page.id, 3).fetch('placements')).to eq([
|
|
||||||
{ 'section_name' => '空の区分', 'description' => nil, 'children' => [] }])
|
|
||||||
expect_group_snapshot(
|
|
||||||
page.id, number: 4, description: '説明文',
|
|
||||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }],
|
|
||||||
placements: [{ 'tag_id' => tag.id, 'children' => [] }] * 2)
|
|
||||||
expect(group_rows('tag_groups', id: page.id).sole).to include(
|
|
||||||
'created_at' => page.created_at, 'updated_at' => page.reload.updated_at)
|
|
||||||
expect(group_state.except(*TagGroupRecords::GROUP_TABLES)).to eq(before_state)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'migrates nested list Sections and child groups as different placement kinds' do
|
|
||||||
series = group_tag('ぼざろクリーチャーシリーズ')
|
|
||||||
deer = group_tag('洗澡鹿')
|
|
||||||
nijika = group_tag('伊地知ニジカ')
|
|
||||||
child = legacy_group('伊地知ニジカ亜種', <<~MARKDOWN)
|
|
||||||
- ぼざろクリーチャーシリーズ
|
|
||||||
- 伊地知ニジカ亜種:
|
|
||||||
- 洗澡鹿
|
|
||||||
MARKDOWN
|
|
||||||
parent = legacy_group('ぼざろクリーチャーシリーズのキャラクター', <<~MARKDOWN)
|
|
||||||
- ぼざろクリーチャーシリーズ
|
|
||||||
- 伊地知ニジカ
|
|
||||||
- タググループ:伊地知ニジカ亜種
|
|
||||||
MARKDOWN
|
|
||||||
|
|
||||||
migrate_groups
|
|
||||||
|
|
||||||
expect_group_snapshot(
|
|
||||||
child.id, number: 1,
|
|
||||||
members: [series, deer].map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
|
|
||||||
placements: [{ 'tag_id' => series.id, 'children' => [
|
|
||||||
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
|
|
||||||
'children' => [{ 'tag_id' => deer.id, 'children' => [] }] }] }])
|
|
||||||
expect_group_snapshot(
|
|
||||||
parent.id, number: 1,
|
|
||||||
members: [
|
|
||||||
{ 'tag_id' => series.id, 'version_no' => series.version_no },
|
|
||||||
{ 'tag_id' => nijika.id, 'version_no' => nijika.version_no },
|
|
||||||
{ 'tag_group_id' => child.id, 'version_no' => 1 }],
|
|
||||||
placements: [{ 'tag_id' => series.id, 'children' => [
|
|
||||||
{ 'tag_id' => nijika.id, 'children' => [
|
|
||||||
{ 'tag_group_id' => child.id, 'children' => [] }] }] }])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'preserves heading order, group paragraphs and Section descriptions' do
|
|
||||||
names = ['タグ希望', 'キャラクター名希望', '楽曲情報希望', 'bot操作', 'ニジラー情報不詳']
|
|
||||||
tags = names.map { group_tag(_1) }
|
|
||||||
markdown = <<~MARKDOWN
|
|
||||||
全体の説明
|
|
||||||
|
|
||||||
# 要求
|
|
||||||
要求の説明
|
|
||||||
|
|
||||||
- タグ希望
|
|
||||||
- キャラクター名希望
|
|
||||||
- 楽曲情報希望
|
|
||||||
|
|
||||||
# 情報
|
|
||||||
- bot操作
|
|
||||||
- ニジラー情報不詳
|
|
||||||
MARKDOWN
|
|
||||||
page = legacy_group('メタタグ', markdown)
|
|
||||||
expect(page.current_revision.body).to eq(markdown.chomp)
|
|
||||||
|
|
||||||
migrate_groups
|
|
||||||
|
|
||||||
leaves = tags.map { { 'tag_id' => _1.id, 'children' => [] } }
|
|
||||||
leaves.first['children'] = leaves[1..2]
|
|
||||||
expect_group_snapshot(
|
|
||||||
page.id, number: 1, description: '全体の説明',
|
|
||||||
members: tags.map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
|
|
||||||
placements: [
|
|
||||||
{ 'section_name' => '要求', 'description' => '要求の説明',
|
|
||||||
'children' => [leaves.first] },
|
|
||||||
{ 'section_name' => '情報', 'description' => nil,
|
|
||||||
'children' => leaves[3..4] }])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'leaves Help in the legacy domain' do
|
|
||||||
page = Wiki::Commit.create_legacy_content!(
|
|
||||||
tag_name: create(:tag_name, name: 'Help:移行対象外'),
|
|
||||||
body: 'Help 本文', created_by_user: user)
|
|
||||||
before_state = group_state
|
|
||||||
migrate_groups
|
|
||||||
expect(group_state).to eq(before_state)
|
|
||||||
expect(page.reload.body).to eq('Help 本文')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'TagName database invariants' do
|
RSpec.describe 'TagName database invariants' do
|
||||||
def insert_name!(**attributes)
|
def insert_name! **attributes
|
||||||
TagName.insert_all!([{ name: 'constraint_name', language_code: 'ja',
|
defaults = {
|
||||||
script_code: 'Jpan', primary_flg: true,
|
tag_id: create(:tag, primary_tag_name: nil).id,
|
||||||
created_at: Time.current, updated_at: Time.current,
|
name: 'constraint_name',
|
||||||
**attributes }])
|
language_code: 'ja',
|
||||||
|
script_code: 'Jpan',
|
||||||
|
primary_flg: true,
|
||||||
|
created_at: Time.current,
|
||||||
|
updated_at: Time.current }
|
||||||
|
|
||||||
|
TagName.insert_all!([defaults.merge(attributes)])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'defaults persisted names to human-confirmed provenance' do
|
it 'defaults persisted names to human-confirmed provenance' do
|
||||||
@@ -33,4 +39,9 @@ RSpec.describe 'TagName database invariants' do
|
|||||||
.to raise_error(ActiveRecord::RecordNotUnique)
|
.to raise_error(ActiveRecord::RecordNotUnique)
|
||||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects a name without a tag' do
|
||||||
|
expect { insert_name!(tag_id: nil) }
|
||||||
|
.to raise_error(ActiveRecord::NotNullViolation)
|
||||||
|
end
|
||||||
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 }
|
auto_generated { false }
|
||||||
sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" }
|
sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" }
|
||||||
|
|
||||||
|
association :tag, primary_tag_name: nil
|
||||||
|
|
||||||
trait :alias do
|
trait :alias do
|
||||||
primary_flg { false }
|
primary_flg { false }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ FactoryBot.define do
|
|||||||
primary_name { nil }
|
primary_name { nil }
|
||||||
primary_tag_name do
|
primary_tag_name do
|
||||||
attributes = primary_name.nil? ? { } : { name: primary_name }
|
attributes = primary_name.nil? ? { } : { name: primary_name }
|
||||||
build(:tag_name, **attributes)
|
build(:tag_name, tag: nil, **attributes)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -12,17 +12,16 @@ FactoryBot.define do
|
|||||||
post_count { 0 }
|
post_count { 0 }
|
||||||
|
|
||||||
after(:build) do |tag, evaluator|
|
after(:build) do |tag, evaluator|
|
||||||
|
next unless evaluator.primary_tag_name
|
||||||
|
|
||||||
evaluator.primary_tag_name.primary_flg = true
|
evaluator.primary_tag_name.primary_flg = true
|
||||||
|
evaluator.primary_tag_name.tag = tag
|
||||||
tag.tag_names.target << evaluator.primary_tag_name
|
tag.tag_names.target << evaluator.primary_tag_name
|
||||||
end
|
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|
|
after(:create) do |tag, evaluator|
|
||||||
|
next unless evaluator.primary_tag_name
|
||||||
|
|
||||||
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
|
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
|
||||||
|
|
||||||
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
|
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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,6 +34,66 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
|||||||
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
|
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
|
||||||
end
|
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
|
context 'when only another language has the sanitised name' do
|
||||||
include_context 'English locale'
|
include_context 'English locale'
|
||||||
|
|
||||||
@@ -98,25 +158,6 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
|||||||
end
|
end
|
||||||
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
|
context 'when the sanitised name is an alias of an existing tag' do
|
||||||
let!(:existing_tag) { create(:tag) }
|
let!(:existing_tag) { create(:tag) }
|
||||||
|
|||||||
@@ -22,16 +22,23 @@ RSpec.describe TagName, type: :model do
|
|||||||
expect(duplicate.errors.of_kind?(:name, :taken)).to be(true)
|
expect(duplicate.errors.of_kind?(:name, :taken)).to be(true)
|
||||||
end
|
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)
|
name = create(:tag_name)
|
||||||
|
|
||||||
expect(name.reload).to have_attributes(
|
expect(name.reload).to have_attributes(
|
||||||
language_code: 'ja', script_code: 'Jpan', primary_flg: true,
|
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
|
end
|
||||||
|
|
||||||
it 'defaults ordinary persisted human names to confirmed provenance' do
|
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)
|
script_code: 'Jpan', primary_flg: true)
|
||||||
|
|
||||||
expect(name.reload.auto_generated).to be(false)
|
expect(name.reload.auto_generated).to be(false)
|
||||||
@@ -46,7 +53,7 @@ RSpec.describe TagName, type: :model do
|
|||||||
expect(name.tag).to eq(tag)
|
expect(name.tag).to eq(tag)
|
||||||
expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true))
|
expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true))
|
||||||
.to eq(name)
|
.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
|
end
|
||||||
|
|
||||||
it 'creates a complete ordinary tag factory under the active Nipponese language' do
|
it 'creates a complete ordinary tag factory under the active Nipponese language' do
|
||||||
@@ -96,19 +103,14 @@ RSpec.describe TagName, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '.canonicalise' do
|
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
|
it 'resolves an alias to its owning primary name' do
|
||||||
create(:tag_name, :alias, name: 'detached_alias')
|
tag = create(:tag, primary_name: 'canonical')
|
||||||
create(:tag_name, name: 'unrelated_detached_primary')
|
create(:tag_name, :alias, tag:, name: 'alias')
|
||||||
|
|
||||||
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
|
expect(described_class.canonicalise(Locale.nipponese, ['alias', 'unknown']))
|
||||||
.to eq(['detached_alias'])
|
.to eq(['canonical', 'unknown'])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves the primary name of the alias language on the same tag' do
|
it 'resolves the primary name of the alias language on the same tag' do
|
||||||
@@ -119,7 +121,7 @@ RSpec.describe TagName, type: :model do
|
|||||||
english_primary.update!(name: 'english_primary', auto_generated: false)
|
english_primary.update!(name: 'english_primary', auto_generated: false)
|
||||||
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
|
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'en', script_code: 'Latn')
|
||||||
representative_id = tag.tag_name_id
|
representative_id = tag.tag_name('ja')&.id
|
||||||
|
|
||||||
primary = described_class.find_by!(tag_id: english_alias.tag_id,
|
primary = described_class.find_by!(tag_id: english_alias.tag_id,
|
||||||
language_code: english_alias.language_code,
|
language_code: english_alias.language_code,
|
||||||
@@ -128,7 +130,7 @@ RSpec.describe TagName, type: :model do
|
|||||||
expect(primary).to eq(english_primary)
|
expect(primary).to eq(english_primary)
|
||||||
expect(described_class.canonicalise(locale, [english_alias.name]))
|
expect(described_class.canonicalise(locale, [english_alias.name]))
|
||||||
.to eq([english_primary.name])
|
.to eq([english_primary.name])
|
||||||
expect(tag.reload.tag_name_id).to eq(representative_id)
|
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves only aliases in the requested language' do
|
it 'resolves only aliases in the requested language' do
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,42 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Tag, type: :model do
|
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: 'en')).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: 'en')).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: 'en', body: '', version_no: 1)]
|
||||||
|
tag.association(:tag_descriptions).loaded!
|
||||||
|
expect(tag.has_description?(language_code: 'en')).to be(true)
|
||||||
|
expect(tag.has_description?(language_code: 'ja')).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'external tag separation' do
|
describe 'external tag separation' do
|
||||||
['nico:reserved', 'NiCo:reserved'].each do |name|
|
['nico:reserved', 'NiCo:reserved'].each do |name|
|
||||||
it "rejects the reserved prefix #{ name } for internal tags" do
|
it "rejects the reserved prefix #{ name } for internal tags" do
|
||||||
@@ -293,38 +329,7 @@ RSpec.describe Tag, type: :model do
|
|||||||
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
|
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
|
||||||
end
|
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
|
it 'creates separate tag identities for the same name in different languages' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||||
@@ -349,7 +354,7 @@ RSpec.describe Tag, type: :model do
|
|||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||||
locale
|
locale
|
||||||
tag = create(:tag, primary_name: '日本語代表名')
|
tag = create(:tag, primary_name: '日本語代表名')
|
||||||
representative_id = tag.tag_name_id
|
representative_id = tag.tag_name('ja')&.id
|
||||||
english = primary_tag_name_for(tag, 'en')
|
english = primary_tag_name_for(tag, 'en')
|
||||||
english.update!(name: 'english_primary', auto_generated: false)
|
english.update!(name: 'english_primary', auto_generated: false)
|
||||||
|
|
||||||
@@ -357,7 +362,7 @@ RSpec.describe Tag, type: :model do
|
|||||||
locale, english.name, category: :general)
|
locale, english.name, category: :general)
|
||||||
|
|
||||||
expect(found).to eq(tag)
|
expect(found).to eq(tag)
|
||||||
expect(tag.reload.tag_name_id).to eq(representative_id)
|
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
||||||
expect(english.reload.tag_id).to eq(tag.id)
|
expect(english.reload.tag_id).to eq(tag.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -389,22 +394,10 @@ RSpec.describe Tag, type: :model do
|
|||||||
expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false)
|
expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false)
|
||||||
end
|
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
|
it 'reuses the owning tag for an alias without changing its category' do
|
||||||
tag = create(:tag, category: :character)
|
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:)
|
alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:)
|
||||||
expect(alias_name.reload).to have_attributes(
|
expect(alias_name.reload).to have_attributes(
|
||||||
tag_id: tag.id,
|
tag_id: tag.id,
|
||||||
@@ -418,7 +411,7 @@ RSpec.describe Tag, type: :model do
|
|||||||
|
|
||||||
expect(found).to eq(tag)
|
expect(found).to eq(tag)
|
||||||
expect(found.category).to eq('character')
|
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
|
end
|
||||||
|
|
||||||
it 'reuses the owning tag through another alias' do
|
it 'reuses the owning tag through another alias' do
|
||||||
@@ -642,7 +635,7 @@ RSpec.describe Tag, type: :model do
|
|||||||
let!(:wiki_page) do
|
let!(:wiki_page) do
|
||||||
admin = create_admin_user!
|
admin = create_admin_user!
|
||||||
|
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: source_tag_name,
|
tag_name: source_tag_name,
|
||||||
body: 'source wiki body',
|
body: 'source wiki body',
|
||||||
created_by_user: admin,
|
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
|
||||||
@@ -178,6 +178,7 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /materials/:id' do
|
describe 'GET /materials/:id' do
|
||||||
|
include WikiRecords
|
||||||
let!(:tag) do
|
let!(:tag) do
|
||||||
create(:tag, primary_name: 'material_show', category: :material)
|
create(:tag, primary_name: 'material_show', category: :material)
|
||||||
end
|
end
|
||||||
@@ -205,6 +206,54 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
get '/materials/999999999'
|
get '/materials/999999999'
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe 'POST /materials' do
|
describe 'POST /materials' do
|
||||||
|
|||||||
@@ -1,36 +1,24 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||||
|
include WikiRecords
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:tag_name) { create(:tag_name, name: 'description_contract') }
|
let!(:tag_name) { create(:tag_name, name: 'description_contract') }
|
||||||
let!(:page) do
|
let!(:tag) { tag_name.tag }
|
||||||
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!(:versions) do
|
let!(:versions) do
|
||||||
revisions.each_with_index.map do |revision, index|
|
['A', 'A', 'new-version'].each_with_index.map do |body, index|
|
||||||
TagDescriptionVersion.create!(
|
TagDescriptionVersion.create!(
|
||||||
id: revision.id, tag_id: tag.id, language_code: 'ja',
|
tag_id: tag.id, language_code: 'ja', version_no: index + 1,
|
||||||
version_no: index + 1, event_type: index.zero? ? 'create' : 'update',
|
event_type: index.zero? ? 'create' : 'update', body:,
|
||||||
body: index == 2 ? 'new-version' : 'A',
|
created_by_user_id: user.id, created_at: Time.current)
|
||||||
created_by_user_id: user.id, created_at: revision.created_at)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
let!(:description) do
|
let!(:description) do
|
||||||
TagDescription.create!(
|
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)
|
created_by_user_id: user.id, updated_by_user_id: user.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
before { page.update_columns(body: 'old') }
|
|
||||||
|
|
||||||
# Observe real SELECTs, including cached queries, only during the request.
|
# Observe real SELECTs, including cached queries, only during the request.
|
||||||
def without_legacy_reads
|
def without_legacy_reads
|
||||||
queries = []
|
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
|
it 'does not fall back to a revision absent from new history' do
|
||||||
versions.first.destroy!
|
versions.first.destroy!
|
||||||
without_legacy_reads do
|
without_legacy_reads do
|
||||||
get "/wiki/#{ description.id }", params: { version: revisions.first.id }
|
get "/wiki/#{ description.id }", params: { version: versions.first.id }
|
||||||
end
|
end
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
@@ -131,16 +119,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
|||||||
'pred' => versions.first.id, 'succ' => versions.last.id)
|
'pred' => versions.first.id, 'succ' => versions.last.id)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe 'exists' do
|
describe 'exists' do
|
||||||
@@ -154,28 +132,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
|||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe 'diff' do
|
describe 'diff' do
|
||||||
@@ -228,39 +184,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
|||||||
'title' => tag_name.name)
|
'title' => tag_name.name)
|
||||||
end
|
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
|
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
|
describe 'index' do
|
||||||
[nil, 'description_contract'].each do |title|
|
[nil, 'description_contract'].each do |title|
|
||||||
|
|||||||
@@ -1,273 +1,93 @@
|
|||||||
require 'rails_helper'
|
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!(:user) { create_member_user! }
|
||||||
let!(:tag_name) { create(:tag_name, name: 'description_write') }
|
let!(:tag) { create(:tag, primary_name: 'description_write') }
|
||||||
let(:tag) { create(:tag, primary_tag_name: tag_name) }
|
|
||||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||||
|
|
||||||
def stored_state
|
def stored_state
|
||||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
|
[Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model|
|
||||||
TagName, TagVersion].map do |model|
|
|
||||||
model.order(:id).map(&:attributes)
|
model.order(:id).map(&:attributes)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Fail after a real INSERT/UPDATE, exercising rollback rather than a mock write.
|
it 'creates a native description and exposes its version ID as revision ID' do
|
||||||
def fail_save model
|
expect do
|
||||||
failure = Class.new(StandardError)
|
post '/wiki', params: { title: tag.name('ja'), body: 'initial' }, headers: headers
|
||||||
callback = ->(_record) { raise failure, 'injected persistence failure' }
|
end.to change(TagDescription, :count).by(1)
|
||||||
model.set_callback(:save, :after, callback)
|
.and change(TagDescriptionVersion, :count).by(1)
|
||||||
expect { yield }.to raise_error(failure)
|
expect(response).to have_http_status(:created)
|
||||||
ensure
|
description = TagDescription.find(json.fetch('id'))
|
||||||
model.skip_callback(:save, :after, callback)
|
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
|
end
|
||||||
|
|
||||||
describe 'POST /wiki' do
|
context 'with an existing description' do
|
||||||
before { tag }
|
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
|
def update_description body: 'after', title: tag.name('ja'), base: first_version.id
|
||||||
post '/wiki', params: { title: tag_name.name, body: 'initial', message: 'create' },
|
put "/wiki/#{ description.id }", params: { title:, body:, base_revision_id: base },
|
||||||
headers: headers
|
headers: headers
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates both current records and histories with a shared revision ID' do
|
it 'records a body change without changing resource identity or old history' do
|
||||||
expect { create_description }
|
snapshot = first_version.attributes
|
||||||
.to change(WikiPage, :count).by(1)
|
expect { update_description }.to change(TagDescriptionVersion, :count).by(1)
|
||||||
.and change(WikiRevision, :count).by(1)
|
expect(response).to have_http_status(:ok)
|
||||||
.and change(WikiVersion, :count).by(1)
|
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
|
||||||
.and change(TagDescription, :count).by(1)
|
expect(first_version.reload.attributes).to eq(snapshot)
|
||||||
.and change(TagDescriptionVersion, :count).by(1)
|
expect(current_description_version(description)).to have_attributes(
|
||||||
expect(response).to have_http_status(:created)
|
body: 'after', event_type: 'update', created_by_user_id: user.id)
|
||||||
|
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
['missing_description', 'orphan_description'].each do |title|
|
['before', "before\r\n\r\n"].each do |body|
|
||||||
it "rejects the bare title #{ title } without writing any resource" do
|
it "does not write for normalised-equivalent body #{ body.inspect }" do
|
||||||
create(:tag_name, name: title) if title == 'orphan_description'
|
|
||||||
before_state = stored_state
|
before_state = stored_state
|
||||||
|
update_description(body:)
|
||||||
post '/wiki', params: { title:, body: 'initial' }, headers: headers
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json).to include('type' => 'validation_error')
|
|
||||||
expect(stored_state).to eq(before_state)
|
expect(stored_state).to eq(before_state)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a namespaced legacy Wiki resource without a Tag' do
|
it 'records a title-only edit in Tag history without adding body history' do
|
||||||
expect do
|
snapshot = description.attributes
|
||||||
post '/wiki', params: { title: 'Help:description_write', body: 'legacy' },
|
expect { update_description(body: 'before', title: 'renamed') }
|
||||||
headers: headers
|
.to change(TagVersion, :count).by(2)
|
||||||
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)
|
|
||||||
.and change(TagDescriptionVersion, :count).by(0)
|
.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)
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(tag.reload.name('ja')).to eq('renamed')
|
||||||
revision = page.reload.current_revision
|
expect(description.reload.attributes).to eq(snapshot)
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'uses description identity even when its Wiki TagName no longer owns the Tag' do
|
['conflicting body', 'after'].each do |body|
|
||||||
tag_name.update_columns(tag_id: nil)
|
it "rejects a stale revision even for #{ body } without a partial rename" do
|
||||||
|
update_description
|
||||||
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:)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(WikiRevision.count).to eq(before_state[1].length)
|
before_state = stored_state
|
||||||
expect(WikiVersion.count).to eq(before_state[2].length)
|
update_description(body:, title: 'conflicting rename')
|
||||||
expect(TagDescriptionVersion.count).to eq(before_state[4].length)
|
expect(response).to have_http_status(:conflict)
|
||||||
expect(page.reload.version_no).to eq(page_version_no)
|
expect(stored_state).to eq(before_state)
|
||||||
expect(description.reload.version_no).to eq(description_version_no)
|
end
|
||||||
expect(page.current_revision.id).to eq(current_revision_id)
|
end
|
||||||
expect(page.reload.attributes).to eq(page_state)
|
|
||||||
expect(description.reload.attributes).to eq(description_state)
|
[TagDescription, TagDescriptionVersion].each do |model|
|
||||||
expect(WikiLine.order(:id).map(&:attributes)).to eq(wiki_lines)
|
it "rolls back the rename and body after #{ model.name } persistence fails" do
|
||||||
expect(WikiRevisionLine.order(:id).map(&:attributes)).to eq(revision_lines)
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tag description native API', type: :request do
|
RSpec.describe 'Tag description native API', type: :request do
|
||||||
|
include WikiRecords
|
||||||
|
include TagGroupRecords
|
||||||
let!(:author) { create_member_user! }
|
let!(:author) { create_member_user! }
|
||||||
let(:editor) { create(:user, :member, name: 'description editor') }
|
let(:editor) { create(:user, :member, name: 'description editor') }
|
||||||
let(:tag_name) { create(:tag_name, name: 'native_description') }
|
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" }
|
let(:endpoint) { "/tags/#{ tag.id }/description" }
|
||||||
|
|
||||||
def stored_state
|
def stored_state
|
||||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
|
[TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model|
|
||||||
WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model|
|
|
||||||
model.order(:id).map(&:attributes)
|
model.order(:id).map(&:attributes)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -84,7 +85,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
describe 'native reads' do
|
describe 'native reads' do
|
||||||
let!(:description) do
|
let!(:description) do
|
||||||
TagDescription.create!(
|
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)
|
version_no: 1, created_by_user_id: author.id, updated_by_user_id: editor.id)
|
||||||
end
|
end
|
||||||
let!(:version) do
|
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" }
|
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
|
||||||
|
|
||||||
it 'reads the current resource by Tag ID without a WikiPage' do
|
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) }
|
without_legacy_reads { native_get(endpoint) }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect_current(description)
|
expect_current(description)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not interpret a compatibility handle as a Tag ID' do
|
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!(
|
other = TagDescription.create!(
|
||||||
tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1,
|
tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1,
|
||||||
created_by_user_id: author.id, updated_by_user_id: author.id)
|
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)
|
expect_current(description)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
describe 'missing native resources' do
|
describe 'missing native resources' do
|
||||||
@@ -206,35 +180,24 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
context 'without an existing description' do
|
context 'without an existing description' do
|
||||||
before { tag }
|
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') }
|
expect { native_write(body: 'created natively') }
|
||||||
.to change(TagDescription, :count).by(1)
|
.to change(TagDescription, :count).by(1)
|
||||||
.and change(TagDescriptionVersion, :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)
|
expect(response).to have_http_status(:created)
|
||||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
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)
|
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 }"
|
get "/wiki/#{ description.id }"
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json).to include('body' => description.body, 'revision_id' => version.id)
|
expect(json).to include('body' => description.body, 'revision_id' => version.id)
|
||||||
end
|
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
|
it "rolls back native create when #{ model.name } persistence fails" do
|
||||||
headers
|
headers
|
||||||
before_state = stored_state
|
before_state = stored_state
|
||||||
@@ -249,7 +212,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
context 'with an existing description' do
|
context 'with an existing description' do
|
||||||
let!(:page) do
|
let!(:page) do
|
||||||
tag
|
tag
|
||||||
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author)
|
seed_description(tag_name:, body: 'before', created_by_user: author)
|
||||||
end
|
end
|
||||||
let!(:description) do
|
let!(:description) do
|
||||||
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
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(page.reload.version_no).to eq(1)
|
||||||
expect(description.version_no).to eq(1)
|
expect(description.version_no).to eq(1)
|
||||||
expect { native_write(body: 'after') }
|
expect { native_write(body: 'after') }
|
||||||
.to change(WikiRevision, :count).by(1)
|
.to change(TagDescriptionVersion, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
.and change(TagDescriptionVersion, :count).by(1)
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(description.reload).to have_attributes(
|
expect(description.reload).to have_attributes(
|
||||||
body: 'after', version_no: 2,
|
body: 'after', version_no: 2,
|
||||||
created_by_user_id: author.id, updated_by_user_id: editor.id)
|
created_by_user_id: author.id, updated_by_user_id: editor.id)
|
||||||
expect(page.reload).to have_attributes(body: 'after', version_no: 2)
|
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)
|
version = TagDescriptionVersion.find(revision.id)
|
||||||
expect(version).to have_attributes(
|
expect(version).to have_attributes(
|
||||||
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
|
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
|
||||||
body: 'after', created_by_user_id: editor.id)
|
body: 'after', created_by_user_id: editor.id)
|
||||||
expect(revision.body).to eq(version.body)
|
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 }"
|
get "/wiki/#{ description.id }"
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json).to include('body' => 'after', 'revision_id' => version.id)
|
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
|
before_state = stored_state
|
||||||
page_state = page.reload.attributes
|
page_state = page.reload.attributes
|
||||||
description_state = description.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:)
|
native_write(body:)
|
||||||
|
|
||||||
@@ -293,11 +254,11 @@ RSpec.describe 'Tag description native API', type: :request do
|
|||||||
expect(stored_state).to eq(before_state)
|
expect(stored_state).to eq(before_state)
|
||||||
expect(page.reload.attributes).to eq(page_state)
|
expect(page.reload.attributes).to eq(page_state)
|
||||||
expect(description.reload.attributes).to eq(description_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
|
||||||
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
|
it "rolls back native update when #{ model.name } persistence fails" do
|
||||||
headers
|
headers
|
||||||
before_state = stored_state
|
before_state = stored_state
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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' }
|
||||||
|
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' }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('body')).to include('renamed_ainu')
|
||||||
|
get "/wiki/#{ group.id }", params: { locale: 'ain', 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', 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', 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' }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('id')).to eq(id)
|
||||||
|
get "#{ path }/exists", params: { locale: 'ain' }
|
||||||
|
expect(response).to have_http_status(:no_content)
|
||||||
|
get '/wiki/search', params: { locale: 'ain', title: "#{ prefix }parent" }
|
||||||
|
expect(json.map { _1.fetch('id') }).to eq([id])
|
||||||
|
put "/wiki/#{ id }", params: {
|
||||||
|
locale: 'ain', title: "#{ prefix }renamed", body: '- ainu_member' }, headers: headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
get "/tag_groups/#{ id }", params: { locale: 'ain' }
|
||||||
|
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', 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', 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' }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
body = json.fetch('body')
|
||||||
|
put "/wiki/#{ id }", params: { locale: 'ain', 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', version: version.id }
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.body).not_to include('historical_japanese', 'current_ainu')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -115,6 +115,15 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
|
|||||||
MARKDOWN
|
MARKDOWN
|
||||||
end
|
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
|
it 'rejects mixed top-level heading Sections and Tags without losing structure' do
|
||||||
id = seed_group(
|
id = seed_group(
|
||||||
name: '混在構造', members: [tag_member(series)],
|
name: '混在構造', members: [tag_member(series)],
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||||
|
include WikiRecords
|
||||||
include TagGroupRecords
|
include TagGroupRecords
|
||||||
|
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:old_tag) { group_tag('旧タグ') }
|
let!(:old_tag) { group_tag('旧タグ') }
|
||||||
let!(:new_tag) { group_tag('新タグ') }
|
let!(:new_tag) { group_tag('新タグ') }
|
||||||
let!(:page) { legacy_group('読取り契約', '- 旧タグ') }
|
let!(:page) { create_markdown_group('読取り契約', '- 旧タグ') }
|
||||||
let!(:first_revision) { page.current_revision }
|
let!(:first_revision) { page.versions.sole }
|
||||||
let!(:last_revision) do
|
let!(:last_revision) do
|
||||||
Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user)
|
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
|
end
|
||||||
|
|
||||||
before do
|
before { I18n.locale = :ja }
|
||||||
I18n.locale = :ja
|
|
||||||
migrate_groups
|
|
||||||
page.update_columns(body: 'legacy stale current')
|
|
||||||
page.wiki_revisions.each do |revision|
|
|
||||||
revision.wiki_lines.update_all(body: 'legacy stale revision')
|
|
||||||
end
|
|
||||||
page.wiki_versions.update_all(body: 'legacy stale version')
|
|
||||||
end
|
|
||||||
|
|
||||||
[:id, :title].each do |lookup|
|
[:id, :title].each do |lookup|
|
||||||
context "with #{ lookup } lookup" do
|
context "with #{ lookup } lookup" do
|
||||||
@@ -69,35 +67,15 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do
|
it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
TagDescriptionOperation.create!(
|
||||||
tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user)
|
Locale.nipponese, tag: new_tag, body: 'tag description', created_by_user: user)
|
||||||
TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user)
|
|
||||||
get '/wiki'
|
get '/wiki'
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.map { _1.fetch('title') }).to include(
|
expect(json.map { _1.fetch('title') }).to include(
|
||||||
'タググループ:読取り契約', help.title, '新タグ')
|
'タググループ:読取り契約', '新タグ')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps Help readable when its ID collides with a new group' do
|
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
|
||||||
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
|
|
||||||
created_by_user: user)
|
|
||||||
seed_group(name: '別 ID重複', id: help.id, description: 'group body')
|
|
||||||
|
|
||||||
get '/wiki'
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.map { _1.fetch('title') }).to include(help.title, 'タググループ:別 ID重複')
|
|
||||||
|
|
||||||
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.fetch('body')).to eq('help body')
|
|
||||||
|
|
||||||
get '/wiki/changes'
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.map { _1.dig('wiki_page', 'title') }).to include(
|
|
||||||
help.title, 'タググループ:別 ID重複')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'finds groups by a partial prefix of their displayed title' do
|
it 'finds groups by a partial prefix of their displayed title' do
|
||||||
get '/wiki', params: { title: 'タググル' }
|
get '/wiki', params: { title: 'タググル' }
|
||||||
@@ -109,8 +87,10 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'does not restore a stale namespaced Wiki page after a group rename' do
|
it 'does not restore a stale namespaced Wiki page after a group rename' do
|
||||||
old_page = legacy_group('旧名称', '- 旧本文')
|
group = create_markdown_group('旧名称', '正本')
|
||||||
seed_group(name: '新名称', id: old_page.id, description: '正本')
|
TagGroupOperation.update!(
|
||||||
|
Locale.nipponese,
|
||||||
|
group:, name: '新名称', description: '正本', members: [], placements: [], actor: user)
|
||||||
|
|
||||||
get group_title_path('旧名称')
|
get group_title_path('旧名称')
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
@@ -122,12 +102,9 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'orders mixed resource history by timestamp rather than independent IDs' do
|
it 'orders mixed resource history by timestamp rather than independent IDs' do
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
TagDescriptionOperation.create!(
|
||||||
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
|
Locale.nipponese, tag: new_tag, body: 'description', created_by_user: user)
|
||||||
created_by_user: user)
|
|
||||||
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
|
|
||||||
TagGroupVersion.update_all(created_at: 3.days.ago)
|
TagGroupVersion.update_all(created_at: 3.days.ago)
|
||||||
WikiRevision.where(wiki_page_id: help.id).update_all(created_at: 2.days.ago)
|
|
||||||
TagDescriptionVersion.update_all(created_at: 1.day.ago)
|
TagDescriptionVersion.update_all(created_at: 1.day.ago)
|
||||||
|
|
||||||
get '/wiki/changes'
|
get '/wiki/changes'
|
||||||
@@ -136,8 +113,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
|
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
|
||||||
expect(timestamps).to eq(timestamps.sort.reverse)
|
expect(timestamps).to eq(timestamps.sort.reverse)
|
||||||
titles = json.map { _1.dig('wiki_page', 'title') }
|
titles = json.map { _1.dig('wiki_page', 'title') }
|
||||||
expect(titles.index('新タグ')).to be < titles.index(help.title)
|
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
|
||||||
expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders nullable group editors without raising' do
|
it 'renders nullable group editors without raising' do
|
||||||
@@ -166,19 +142,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
|
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'includes Help and group history but excludes legacy-only migrated revisions' do
|
|
||||||
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
|
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
|
||||||
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
|
|
||||||
get '/wiki/changes'
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' }
|
|
||||||
expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
|
|
||||||
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
|
|
||||||
expect(json).to include(include(
|
|
||||||
'revision_id' => help.current_revision.id,
|
|
||||||
'wiki_page' => include('title' => help.title)))
|
|
||||||
end
|
|
||||||
|
|
||||||
[false, true].each do |explicit|
|
[false, true].each do |explicit|
|
||||||
it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do
|
it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do
|
||||||
@@ -212,93 +175,5 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
expect(json.fetch('body').strip).to eq('独立した説明')
|
expect(json.fetch('body').strip).to eq('独立した説明')
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when Help, description and group share the same numeric ID' do
|
|
||||||
let(:colliding_help) do
|
|
||||||
Wiki::Commit.create_legacy_content!(
|
|
||||||
tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
|
|
||||||
body: 'help', created_by_user: user)
|
|
||||||
end
|
|
||||||
let(:colliding_group_id) do
|
|
||||||
seed_group(name: '三者ID衝突', id: colliding_help.id, description: 'group')
|
|
||||||
end
|
|
||||||
let(:colliding_description) do
|
|
||||||
TagDescription.create!(
|
|
||||||
id: colliding_help.id, tag: new_tag, language_code: 'ja',
|
|
||||||
body: 'description', version_no: 1)
|
|
||||||
end
|
|
||||||
let(:colliding_description_version) do
|
|
||||||
TagDescriptionVersion.create!(
|
|
||||||
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
|
|
||||||
event_type: 'create', body: 'description', created_by_user_id: user.id,
|
|
||||||
created_at: Time.current)
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
colliding_group_id
|
|
||||||
colliding_description
|
|
||||||
colliding_description_version
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'lists all three resources without deduplicating their numeric ID' do
|
|
||||||
get '/wiki'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
rows = json.select { _1.fetch('id') == colliding_help.id }
|
|
||||||
expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly(
|
|
||||||
[colliding_help.id, colliding_help.title],
|
|
||||||
[colliding_description.id, '新タグ'],
|
|
||||||
[colliding_group_id, 'タググループ:三者ID衝突'])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'lists each resource history without deduplicating the shared resource ID' do
|
|
||||||
get '/wiki/changes'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
rows = json.select { _1.fetch('wiki_page').fetch('id') == colliding_help.id }
|
|
||||||
histories = rows.map do |row|
|
|
||||||
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
|
|
||||||
end
|
|
||||||
expect(histories).to contain_exactly(
|
|
||||||
[colliding_help.current_revision.id, colliding_help.title],
|
|
||||||
[colliding_description_version.id, '新タグ'],
|
|
||||||
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'still excludes legacy copies of an actually migrated group and its history' do
|
|
||||||
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
|
|
||||||
|
|
||||||
get '/wiki'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
rows = json.select { _1.fetch('id') == page.id }
|
|
||||||
expect(rows.map { _1.fetch('title') }).to eq(['タググループ:読取り契約'])
|
|
||||||
|
|
||||||
get '/wiki/changes'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
rows = json.select { _1.fetch('wiki_page').fetch('id') == page.id }
|
|
||||||
expect(rows.map { _1.fetch('revision_id') })
|
|
||||||
.to contain_exactly(first_revision.id, last_revision.id)
|
|
||||||
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
|
|
||||||
expect(rows.map { _1.fetch('wiki_page').fetch('title') })
|
|
||||||
.to all(eq('タググループ:読取り契約'))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'resolves colliding group, Help and description IDs by title' do
|
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
|
||||||
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
|
|
||||||
seed_group(name: 'ID衝突', id: help.id, description: 'group')
|
|
||||||
TagDescription.create!(
|
|
||||||
id: help.id, tag: new_tag, language_code: 'ja', body: 'description', version_no: 1)
|
|
||||||
TagDescriptionVersion.create!(
|
|
||||||
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
|
|
||||||
event_type: 'create', body: 'description', created_at: Time.current)
|
|
||||||
{ 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => 'description' }
|
|
||||||
.each do |title, body|
|
|
||||||
get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.fetch('body').strip).to eq(body)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates the group, first snapshot and compatibility Wiki history' do
|
it 'creates the group, snapshot and native version history' do
|
||||||
create_group
|
expect { create_group }
|
||||||
|
.to change(TagGroup, :count).by(1)
|
||||||
|
.and change(TagGroupVersion, :count).by(1)
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
group = group_rows('tag_groups', name: '新規').sole
|
group = group_rows('tag_groups', name: '新規').sole
|
||||||
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
|
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
|
||||||
@@ -42,11 +44,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
version = group_version(group.fetch('id'), 1)
|
version = group_version(group.fetch('id'), 1)
|
||||||
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
|
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)
|
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
|
||||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
|
get group_title_path('新規')
|
||||||
expect(page.body.strip).to eq('- 洗澡鹿')
|
expect(response).to have_http_status(:ok)
|
||||||
expect(page.current_revision.body).to eq(page.body)
|
expect(json.fetch('body')).to eq('- 洗澡鹿')
|
||||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
|
||||||
# New IDs are independently allocated; no global identity assertion here.
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps repeated placements but only one membership and one snapshot member' do
|
it 'keeps repeated placements but only one membership and one snapshot member' do
|
||||||
@@ -106,10 +106,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'updating a migrated group' do
|
context 'updating a migrated group' do
|
||||||
let!(:page) { legacy_group('更新', '- 洗澡鹿') }
|
let!(:page) { create_markdown_group('更新', '- 洗澡鹿') }
|
||||||
let!(:old_revision) { page.current_revision }
|
let!(:old_revision) { page.versions.order(:version_no).last }
|
||||||
|
|
||||||
before { migrate_groups }
|
|
||||||
|
|
||||||
def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers
|
def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers
|
||||||
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
|
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
|
||||||
@@ -117,9 +116,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
|
|
||||||
[1, 3].each do |repetitions|
|
[1, 3].each do |repetitions|
|
||||||
it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do
|
it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do
|
||||||
body = page.body
|
body = group_markdown(page)
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id: page.id, page:) do
|
expect_noop_group_update(group_id: page.id) do
|
||||||
repetitions.times do
|
repetitions.times do
|
||||||
update_group(body:)
|
update_group(body:)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -132,13 +131,13 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
it 'keeps identical Markdown unchanged with a stale base_revision_id' do
|
it 'keeps identical Markdown unchanged with a stale base_revision_id' do
|
||||||
update_group
|
update_group
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
body = page.reload.body
|
body = group_markdown(page)
|
||||||
expect(page.current_revision.id).not_to eq(old_revision.id)
|
expect(page.versions.order(:version_no).last.id).not_to eq(old_revision.id)
|
||||||
|
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id: page.id, page:) do
|
expect_noop_group_update(group_id: page.id) do
|
||||||
put "/wiki/#{ page.id }",
|
put "/wiki/#{ page.id }",
|
||||||
params: { title: page.title, body:, base_revision_id: old_revision.id },
|
params: { title: "タググループ:#{ page.name }", body:, base_revision_id: old_revision.id },
|
||||||
headers: headers
|
headers: headers
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -146,9 +145,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
|
|
||||||
it 'keeps the original editor on identical Markdown from another member' do
|
it 'keeps the original editor on identical Markdown from another member' do
|
||||||
editor = create(:user, :member)
|
editor = create(:user, :member)
|
||||||
body = page.body
|
body = group_markdown(page)
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id: page.id, page:) do
|
expect_noop_group_update(group_id: page.id) do
|
||||||
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -166,11 +165,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
page.id, number: 2,
|
page.id, number: 2,
|
||||||
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
|
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
|
||||||
placements: changed_placements)
|
placements: changed_placements)
|
||||||
body = page.reload.body
|
body = group_markdown(page)
|
||||||
expect(body).to eq('- 伊地知ニジカ')
|
expect(body).to eq('- 伊地知ニジカ')
|
||||||
|
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id: page.id, page:) { update_group(body:) }
|
expect_noop_group_update(group_id: page.id) { update_group(body:) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -185,16 +184,13 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
expect(group_version(page.id, 1)).to eq(old)
|
expect(group_version(page.id, 1)).to eq(old)
|
||||||
expect(group_version(page.id, 2)).to include(
|
expect(group_version(page.id, 2)).to include(
|
||||||
'event_type' => 'update', 'created_by_user_id' => user.id)
|
'event_type' => 'update', 'created_by_user_id' => user.id)
|
||||||
expect(page.reload.body.strip).to eq('- 伊地知ニジカ')
|
expect(group_markdown(page).strip).to eq('- 伊地知ニジカ')
|
||||||
expect(page.wiki_revisions.count).to eq(2)
|
expect(page.versions.count).to eq(2)
|
||||||
expect(page.wiki_versions.count).to eq(2)
|
|
||||||
expect(page.current_revision.body).to eq(page.body)
|
|
||||||
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'accepts the current base_revision_id used by the existing frontend' do
|
it 'accepts the current base_revision_id used by the existing frontend' do
|
||||||
put "/wiki/#{ page.id }",
|
put "/wiki/#{ page.id }",
|
||||||
params: { title: page.title, body: '- 伊地知ニジカ',
|
params: { title: "タググループ:#{ page.name }", body: '- 伊地知ニジカ',
|
||||||
base_revision_id: old_revision.id }, headers: headers
|
base_revision_id: old_revision.id }, headers: headers
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2)
|
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2)
|
||||||
@@ -205,12 +201,12 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
put "/wiki/#{ page.id }",
|
put "/wiki/#{ page.id }",
|
||||||
params: { title: page.title, body: '- 洗澡鹿',
|
params: { title: "タググループ:#{ page.name }", body: '- 洗澡鹿',
|
||||||
base_revision_id: old_revision.id }, headers: headers
|
base_revision_id: old_revision.id }, headers: headers
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(3)
|
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(3)
|
||||||
expect(page.reload.body.strip).to eq('- 洗澡鹿')
|
expect(group_markdown(page).strip).to eq('- 洗澡鹿')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'records the editor and update time' do
|
it 'records the editor and update time' do
|
||||||
@@ -255,9 +251,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
let!(:page) do
|
let!(:page) do
|
||||||
next unless operation == :update
|
next unless operation == :update
|
||||||
|
|
||||||
legacy = legacy_group('更新', '- 洗澡鹿')
|
create_markdown_group('更新', '- 洗澡鹿')
|
||||||
seed_group(name: '更新', id: legacy.id, members:, placements:)
|
|
||||||
legacy
|
|
||||||
end
|
end
|
||||||
let!(:child_id) { seed_group(name: '子グループ', description: '子') }
|
let!(:child_id) { seed_group(name: '子グループ', description: '子') }
|
||||||
|
|
||||||
@@ -293,8 +287,8 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
expect(group_state).to eq(before_state)
|
expect(group_state).to eq(before_state)
|
||||||
end
|
end
|
||||||
|
|
||||||
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
|
tables = TagGroupRecords::GROUP_TABLES
|
||||||
.each do |table|
|
tables.each do |table|
|
||||||
it "rolls back all writes after a real #{ table } write fails" do
|
it "rolls back all writes after a real #{ table } write fails" do
|
||||||
expect_atomic_group_write(table) { write_group(operation, headers) }
|
expect_atomic_group_write(table) { write_group(operation, headers) }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tag group write integrity', type: :request do
|
RSpec.describe 'Tag group write integrity', type: :request do
|
||||||
|
include WikiRecords
|
||||||
include TagGroupRecords
|
include TagGroupRecords
|
||||||
include ActiveSupport::Testing::TimeHelpers
|
include ActiveSupport::Testing::TimeHelpers
|
||||||
|
|
||||||
@@ -23,11 +24,6 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers
|
put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_help body
|
|
||||||
Wiki::Commit.create_legacy_content!(
|
|
||||||
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
|
|
||||||
end
|
|
||||||
|
|
||||||
def expect_no_writes
|
def expect_no_writes
|
||||||
before_state = group_state
|
before_state = group_state
|
||||||
yield
|
yield
|
||||||
@@ -36,69 +32,31 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'resource identity' do
|
context 'resource identity' do
|
||||||
it 'rejects removing the prefix from a migrated group atomically' do
|
|
||||||
page = legacy_group('更新', '- 洗澡鹿')
|
|
||||||
seed_group(name: '更新', id: page.id, members:, placements:)
|
|
||||||
|
|
||||||
expect_no_writes do
|
|
||||||
put_wiki(page.id, title: '更新', base: page.current_revision.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'rejects an unprefixed update to an independent group' do
|
it 'rejects an unprefixed update to an independent group' do
|
||||||
id = seed_group(name: '独立', members:, placements:)
|
id = seed_group(name: '独立', members:, placements:)
|
||||||
expect_no_writes { put_wiki(id, title: '独立') }
|
expect_no_writes { put_wiki(id, title: '独立') }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not revive legacy writes when the compatibility title is stale' do
|
|
||||||
page = legacy_group('更新', '- 洗澡鹿')
|
|
||||||
seed_group(name: '更新', id: page.id, members:, placements:)
|
|
||||||
page.tag_name.update!(name: '古い別名')
|
|
||||||
|
|
||||||
expect_no_writes { put_wiki(page.id, title: '古い別名') }
|
it 'renames the group and its facade title through the operation' do
|
||||||
end
|
page = create_markdown_group('更新', '- 洗澡鹿')
|
||||||
|
|
||||||
it 'renames the group and its compatibility page through the operation' do
|
|
||||||
page = legacy_group('更新', '- 洗澡鹿')
|
|
||||||
seed_group(name: '更新', id: page.id, members:, placements:)
|
|
||||||
|
|
||||||
put_wiki(page.id, title: 'タググループ:改名')
|
put_wiki(page.id, title: 'タググループ:改名')
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(TagGroup.find(page.id)).to have_attributes(name: '改名', version_no: 2)
|
expect(TagGroup.find(page.id)).to have_attributes(name: '改名', version_no: 2)
|
||||||
expect(page.reload.title).to eq('タググループ:改名')
|
expect("タググループ:#{ page.reload.name }").to eq('タググループ:改名')
|
||||||
expect(page.body).to eq('- 伊地知ニジカ')
|
expect(group_markdown(page)).to eq('- 伊地知ニジカ')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates Help with a colliding group ID without altering the group' do
|
|
||||||
page = create_help('Help body')
|
|
||||||
id = seed_group(name: '別グループ', id: page.id, members:, placements:)
|
|
||||||
group_before = TagGroup.find(id).attributes
|
|
||||||
version_before = group_version(id, 1)
|
|
||||||
|
|
||||||
put_wiki(id, title: 'Help:改名', body: 'Help after')
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(page.reload.body).to eq('Help after')
|
|
||||||
expect(page.title).to eq('Help:改名')
|
|
||||||
expect(TagGroup.find(id).attributes).to eq(group_before)
|
|
||||||
expect(group_version(id, 1)).to eq(version_before)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'rejects an ambiguous unprefixed rename when Help and group IDs collide' do
|
|
||||||
page = create_help('Help body')
|
|
||||||
seed_group(name: '更新', id: page.id, members:, placements:)
|
|
||||||
|
|
||||||
expect_no_writes { put_wiki(page.id, title: '更新') }
|
|
||||||
expect_no_writes do
|
|
||||||
put_wiki(page.id, title: '更新', base: page.current_revision.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'keeps a description update separate from a colliding group' do
|
it 'keeps a description update separate from a colliding group' do
|
||||||
page = Wiki::Commit.create_content!(
|
page = seed_description(
|
||||||
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
|
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
|
||||||
id = seed_group(name: '更新', id: page.id, members:, placements:)
|
description = TagDescription.find_by!(tag_id: tag.id)
|
||||||
|
id = seed_group(name: '更新', id: description.id, members:, placements:)
|
||||||
group_before = TagGroup.find(id).attributes
|
group_before = TagGroup.find(id).attributes
|
||||||
|
|
||||||
put_wiki(id, title: '洗澡鹿', body: 'description after')
|
put_wiki(id, title: '洗澡鹿', body: 'description after')
|
||||||
@@ -109,105 +67,22 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
expect(TagGroup.find(id).attributes).to eq(group_before)
|
expect(TagGroup.find(id).attributes).to eq(group_before)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not route a colliding Help write into an unrelated description' do
|
end
|
||||||
page = create_help('Help before')
|
|
||||||
id = seed_group(name: '更新', id: page.id, members:, placements:)
|
|
||||||
description = TagDescription.create!(
|
|
||||||
id:, tag:, language_code: 'ja', version_no: 1, body: 'description')
|
|
||||||
|
|
||||||
put_wiki(id, title: 'Help:既存', body: 'Help after')
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
it 'rolls back a facade create when its saved structure cannot be rendered' do
|
||||||
expect(page.reload.body).to eq('Help after')
|
allow(TagGroupMarkdownRenderer).to receive(:render)
|
||||||
expect(description.reload.body).to eq('description')
|
.and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported')
|
||||||
expect(TagGroup.find(id).version_no).to eq(1)
|
expect_no_writes do
|
||||||
expect_no_writes { put_wiki(id, title: '洗澡鹿', body: 'description after') }
|
post '/wiki', params: { title: 'タググループ:新規', body: '- 洗澡鹿' }, headers: headers
|
||||||
put_wiki(id, title: 'タググループ:更新')
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(TagGroup.find(id).version_no).to eq(2)
|
|
||||||
expect(page.reload.body).to eq('Help after')
|
|
||||||
expect(description.reload.body).to eq('description')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'compatibility synchronisation' do
|
it 'rolls back a facade update when its saved structure cannot be rendered' do
|
||||||
it 'synchronises a native rename without modifying an ID-colliding Help page' do
|
id = seed_group(name: '更新', members:, placements:)
|
||||||
help = create_help('Help before')
|
allow(TagGroupMarkdownRenderer).to receive(:render)
|
||||||
id = seed_group(name: '更新', id: help.id, members:, placements:)
|
.and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported')
|
||||||
help_before = help.attributes
|
expect_no_writes { put_wiki(id, title: 'タググループ:改名') }
|
||||||
|
|
||||||
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' })
|
|
||||||
expect(page.id).not_to eq(help.id)
|
|
||||||
expect(page.body).to eq('- 洗澡鹿')
|
|
||||||
expect(page.current_revision.body).to eq(page.body)
|
|
||||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
|
||||||
expect(help.reload.attributes).to eq(help_before)
|
|
||||||
end
|
|
||||||
|
|
||||||
['wiki_lines', 'wiki_revision_lines'].each do |table|
|
|
||||||
it "rolls back native and compatibility state on #{ table } failure" do
|
|
||||||
expect_atomic_group_write(table) do
|
|
||||||
post '/tag_groups', params: native_payload, headers:, as: :json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'rolls back a renderer failure instead of treating it as unsupported structure' do
|
|
||||||
allow(TagGroupMarkdownRenderer).to receive(:render).and_raise('renderer failure')
|
|
||||||
before_state = group_state
|
|
||||||
|
|
||||||
expect do
|
|
||||||
post '/tag_groups', params: native_payload, headers:, as: :json
|
|
||||||
end.to raise_error(RuntimeError, 'renderer failure')
|
|
||||||
|
|
||||||
expect(group_state).to eq(before_state)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'preserves unplaced membership and reports the skipped sync with its version' do
|
|
||||||
page = legacy_group('更新', '- 洗澡鹿')
|
|
||||||
id = seed_group(name: '更新', id: page.id, members:, placements:)
|
|
||||||
wiki_before = group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))
|
|
||||||
warnings = []
|
|
||||||
allow(Rails.logger).to receive(:warn) { warnings << _1 }
|
|
||||||
|
|
||||||
put "/tag_groups/#{ id }",
|
|
||||||
params: native_payload(member_rows: members + [{ 'tag_id' => other_tag.id }]),
|
|
||||||
headers:, as: :json
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.fetch('members').map { _1.fetch('tag').fetch('id') })
|
|
||||||
.to contain_exactly(tag.id, other_tag.id)
|
|
||||||
expect(group_version(id, 2).fetch('members')).to include(
|
|
||||||
{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no })
|
|
||||||
expect(group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))).to eq(wiki_before)
|
|
||||||
payload = JSON.parse(warnings.grep(/tag_group_wiki_sync_skipped/).sole.split(' ', 2).last)
|
|
||||||
expect(payload).to include(
|
|
||||||
'status' => 'unsupported_structure', 'tag_group_id' => id,
|
|
||||||
'version_no' => 2, 'user_id' => user.id,
|
|
||||||
'reason' => '掲載位置のない membership は Markdown にできません.')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'preserves a list Section description and records why no Wiki was created' do
|
|
||||||
nodes = [{ 'tag_id' => tag.id, 'children' => [
|
|
||||||
{ 'section_name' => '区分', 'description' => '構造化の説明', 'children' => [] }] }]
|
|
||||||
warnings = []
|
|
||||||
allow(Rails.logger).to receive(:warn) { warnings << _1 }
|
|
||||||
before_count = WikiPage.count
|
|
||||||
|
|
||||||
post '/tag_groups', params: native_payload(nodes:), headers:, as: :json
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
id = json.fetch('id')
|
|
||||||
expect(group_version(id, 1).fetch('placements')).to eq(nodes)
|
|
||||||
expect(json.fetch('placements').sole.fetch('children').sole)
|
|
||||||
.to include('description' => '構造化の説明')
|
|
||||||
expect(WikiPage.count).to eq(before_count)
|
|
||||||
expect(warnings.grep(/tag_group_wiki_sync_skipped/).sole)
|
|
||||||
.to include('リスト内 Section の説明文は Markdown にできません.')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'identifier Markdown' do
|
context 'identifier Markdown' do
|
||||||
@@ -291,10 +166,10 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members)
|
expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rolls back membership removal and placement edits on a late sync failure' do
|
it 'rolls back membership removal and placement edits on a version persistence failure' do
|
||||||
new_members = [{ 'tag_id' => tag.id }]
|
new_members = [{ 'tag_id' => tag.id }]
|
||||||
new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }]
|
new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }]
|
||||||
expect_atomic_group_write('wiki_versions') do
|
expect_atomic_group_write('tag_group_versions') do
|
||||||
put "/tag_groups/#{ id }",
|
put "/tag_groups/#{ id }",
|
||||||
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
|
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -234,8 +234,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
|
|
||||||
context 'compatibility writes for representable structures' do
|
context 'compatibility writes for representable structures' do
|
||||||
let(:group_id) do
|
let(:group_id) do
|
||||||
page = legacy_group('専用API', '説明')
|
seed_group(name: '専用API', description: '説明')
|
||||||
seed_group(name: '専用API', id: page.id, description: '説明')
|
|
||||||
end
|
end
|
||||||
let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] }
|
let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] }
|
||||||
let(:snapshot_members) do
|
let(:snapshot_members) do
|
||||||
@@ -252,10 +251,9 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
group_id, number: 2, description: '全体の説明', placements:,
|
group_id, number: 2, description: '全体の説明', placements:,
|
||||||
members: snapshot_members + [
|
members: snapshot_members + [
|
||||||
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }])
|
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }])
|
||||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:専用API' })
|
|
||||||
|
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id:, page:) do
|
expect_noop_group_update(group_id:) do
|
||||||
native_write(:update, unplaced_payload)
|
native_write(:update, unplaced_payload)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -267,20 +265,18 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
members: [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }], placements: }
|
members: [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }], placements: }
|
||||||
end
|
end
|
||||||
let(:additional_tag) { group_tag('追加対象タグ') }
|
let(:additional_tag) { group_tag('追加対象タグ') }
|
||||||
let(:compatibility_page) do
|
|
||||||
WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' })
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
before do
|
||||||
native_write(:update, synchronised_payload)
|
native_write(:update, synchronised_payload)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(compatibility_page.body).to include('- 掲載タグ', '- タググループ:子')
|
expect(group_markdown(TagGroup.find(group_id)))
|
||||||
|
.to include('- 掲載タグ', '- タググループ:子')
|
||||||
end
|
end
|
||||||
|
|
||||||
[1, 3].each do |repetitions|
|
[1, 3].each do |repetitions|
|
||||||
it "keeps identical native content unchanged after #{ repetitions } PUTs" do
|
it "keeps identical native content unchanged after #{ repetitions } PUTs" do
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id:, page: compatibility_page) do
|
expect_noop_group_update(group_id:) do
|
||||||
repetitions.times do
|
repetitions.times do
|
||||||
native_write(:update, synchronised_payload)
|
native_write(:update, synchronised_payload)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -292,7 +288,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
|
|
||||||
it 'treats membership order alone as a no-op' do
|
it 'treats membership order alone as a no-op' do
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id:, page: compatibility_page) do
|
expect_noop_group_update(group_id:) do
|
||||||
native_write(:update, synchronised_payload.merge(
|
native_write(:update, synchronised_payload.merge(
|
||||||
members: synchronised_payload.fetch(:members).reverse))
|
members: synchronised_payload.fetch(:members).reverse))
|
||||||
end
|
end
|
||||||
@@ -302,7 +298,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
it 'keeps the original editor when another member submits identical content' do
|
it 'keeps the original editor when another member submits identical content' do
|
||||||
editor = create(:user, :member)
|
editor = create(:user, :member)
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id:, page: compatibility_page) do
|
expect_noop_group_update(group_id:) do
|
||||||
put "/tag_groups/#{ group_id }", params: synchronised_payload,
|
put "/tag_groups/#{ group_id }", params: synchronised_payload,
|
||||||
headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json
|
headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json
|
||||||
end
|
end
|
||||||
@@ -313,7 +309,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
put "/wiki/#{ group_id }",
|
put "/wiki/#{ group_id }",
|
||||||
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
|
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(compatibility_page.reload.body).to eq('- 未掲載タグ')
|
expect(group_markdown(TagGroup.find(group_id))).to eq('- 未掲載タグ')
|
||||||
same_content = {
|
same_content = {
|
||||||
name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }],
|
name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }],
|
||||||
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] }
|
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] }
|
||||||
@@ -323,7 +319,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
placements: same_content.fetch(:placements))
|
placements: same_content.fetch(:placements))
|
||||||
|
|
||||||
travel_to(1.hour.from_now) do
|
travel_to(1.hour.from_now) do
|
||||||
expect_noop_group_update(group_id:, page: compatibility_page) do
|
expect_noop_group_update(group_id:) do
|
||||||
native_write(:update, same_content)
|
native_write(:update, same_content)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -332,7 +328,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
['name', 'description', 'membership addition', 'membership removal',
|
['name', 'description', 'membership addition', 'membership removal',
|
||||||
'Section name', 'Section description', 'placement order', 'placement hierarchy']
|
'Section name', 'Section description', 'placement order', 'placement hierarchy']
|
||||||
.each do |change|
|
.each do |change|
|
||||||
it "records a real #{ change } change in both histories" do
|
it "records a real #{ change } change in native history" do
|
||||||
changed = synchronised_payload.deep_dup
|
changed = synchronised_payload.deep_dup
|
||||||
section = changed.fetch(:placements).first
|
section = changed.fetch(:placements).first
|
||||||
children = section.fetch('children')
|
children = section.fetch('children')
|
||||||
@@ -356,11 +352,6 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
old_versions = group_rows('tag_group_versions', tag_group_id: group_id)
|
old_versions = group_rows('tag_group_versions', tag_group_id: group_id)
|
||||||
group = TagGroup.find(group_id)
|
group = TagGroup.find(group_id)
|
||||||
number = group.version_no
|
number = group.version_no
|
||||||
page = compatibility_page
|
|
||||||
old_revision_id = page.current_revision.id
|
|
||||||
old_wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id
|
|
||||||
revision_count = page.wiki_revisions.count
|
|
||||||
version_count = page.wiki_versions.count
|
|
||||||
travel_to(1.hour.from_now) { native_write(:update, changed) }
|
travel_to(1.hour.from_now) { native_write(:update, changed) }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -379,18 +370,23 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
versions = group_rows('tag_group_versions', tag_group_id: group_id)
|
versions = group_rows('tag_group_versions', tag_group_id: group_id)
|
||||||
expect(versions.size).to eq(old_versions.size + 1)
|
expect(versions.size).to eq(old_versions.size + 1)
|
||||||
expect(versions.select { _1.fetch('version_no') <= number }).to eq(old_versions)
|
expect(versions.select { _1.fetch('version_no') <= number }).to eq(old_versions)
|
||||||
expect(page.wiki_revisions.count).to eq(revision_count + 1)
|
|
||||||
expect(page.wiki_versions.count).to eq(version_count + 1)
|
|
||||||
expect(page.reload.current_revision.id).not_to eq(old_revision_id)
|
|
||||||
expect(page.wiki_versions.order(version_no: :desc).first.id)
|
|
||||||
.not_to eq(old_wiki_version_id)
|
|
||||||
get group_title_path(changed.fetch(:name))
|
get group_title_path(changed.fetch(:name))
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('body')).to eq(page.body)
|
expect(json.fetch('body')).to eq(group_markdown(group))
|
||||||
end
|
end
|
||||||
end
|
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|
|
[:create, :update].each do |operation|
|
||||||
it "synchronises Wiki current state and histories on native #{ operation }" do
|
it "synchronises Wiki current state and histories on native #{ operation }" do
|
||||||
native_write(operation)
|
native_write(operation)
|
||||||
@@ -399,18 +395,13 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
number = operation == :create ? 1 : 2
|
number = operation == :create ? 1 : 2
|
||||||
expect_group_snapshot(
|
expect_group_snapshot(
|
||||||
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
||||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' })
|
expect(TagGroup.find(id).versions.count).to eq(number)
|
||||||
expect(page.body).to include('全体の説明', '# 区分', '- 掲載タグ', '- タググループ:子')
|
|
||||||
expect(page.current_revision.body).to eq(page.body)
|
|
||||||
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
|
|
||||||
expect(page.wiki_revisions.count).to eq(number)
|
|
||||||
expect(page.wiki_versions.count).to eq(number)
|
|
||||||
get group_title_path('構造化')
|
get group_title_path('構造化')
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('body')).to eq(page.body)
|
expect(json.fetch('body')).to include('全体の説明', '# 区分', '- 掲載タグ')
|
||||||
end
|
end
|
||||||
|
|
||||||
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
|
TagGroupRecords::GROUP_TABLES
|
||||||
.each do |table|
|
.each do |table|
|
||||||
it "rolls back native #{ operation } after #{ table } writes" do
|
it "rolls back native #{ operation } after #{ table } writes" do
|
||||||
expect_atomic_group_write(table) { native_write(operation) }
|
expect_atomic_group_write(table) { native_write(operation) }
|
||||||
@@ -418,19 +409,18 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do
|
it 'assigns the next group ID above the existing maximum' do
|
||||||
inserts = []
|
child_id
|
||||||
subscriber = lambda do |*args|
|
expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1
|
||||||
sql = args.last.fetch(:sql)
|
native_write(:create)
|
||||||
inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i)
|
|
||||||
end
|
|
||||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
|
||||||
native_write(:create)
|
|
||||||
end
|
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
expect(inserts.size).to eq(1)
|
expect(json.fetch('id')).to be >= expected_id
|
||||||
columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') }
|
end
|
||||||
expect(columns).not_to include('id')
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
it 'updates a native group through Wiki and then reads structured current data' do
|
it 'updates a native group through Wiki and then reads structured current data' do
|
||||||
|
|||||||
@@ -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'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'TagVersions API', type: :request do
|
RSpec.describe 'TagVersions API', type: :request do
|
||||||
|
include WikiRecords
|
||||||
let(:member) { create(:user, :member, name: 'version member') }
|
let(:member) { create(:user, :member, name: 'version member') }
|
||||||
|
|
||||||
let!(:tag) { create(:tag, primary_name: 'tag_versions_target', category: :general) }
|
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_tag = create(:tag, primary_tag_name: wiki_tag_name, category: :general)
|
||||||
|
|
||||||
wiki_page =
|
wiki_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: wiki_tag_name,
|
tag_name: wiki_tag_name,
|
||||||
body: 'before',
|
body: 'before',
|
||||||
created_by_user: member,
|
created_by_user: member,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
|
|
||||||
Wiki::Commit.content!(
|
TagDescriptionOperation.update!(
|
||||||
page: wiki_page,
|
Locale.nipponese,
|
||||||
|
tag: wiki_page.tag,
|
||||||
body: 'after',
|
body: 'after',
|
||||||
created_user: member,
|
updated_by_user: member,
|
||||||
message: 'edit',
|
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 }
|
get '/tags/versions', params: { id: wiki_tag.id }
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tag and wiki history integrity', type: :request do
|
RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||||
|
include WikiRecords
|
||||||
let(:member_user) { create(:user, role: 'member') }
|
let(:member_user) { create(:user, role: 'member') }
|
||||||
|
|
||||||
def stub_current_user user
|
def stub_current_user user
|
||||||
@@ -13,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
|
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'),
|
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||||
body:,
|
body:,
|
||||||
created_by_user: user,
|
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
|
it 'renames the current Wiki resource without adding body history' do
|
||||||
tag = create_tag!(name: 'patch_tag_wiki_before')
|
tag = create_tag!(name: 'patch_tag_wiki_before')
|
||||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body 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 {
|
expect {
|
||||||
patch "/tags/#{ tag.id }", params: {
|
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)
|
.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(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -44,16 +45,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
wiki_page.reload
|
wiki_page.reload
|
||||||
|
|
||||||
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after')
|
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
|
end
|
||||||
|
|
||||||
it 'does not record wiki_version when only category changes' do
|
it 'does not record wiki_version when only category changes' do
|
||||||
tag = create_tag!(name: 'patch_tag_category_only')
|
tag = create_tag!(name: 'patch_tag_category_only')
|
||||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
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 {
|
expect {
|
||||||
patch "/tags/#{ tag.id }", params: {
|
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(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_category_only')
|
||||||
expect(tag.category).to eq('meme')
|
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
|
||||||
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
|
it 'renames the current Wiki resource without adding body history' do
|
||||||
tag = create_tag!(name: 'put_tag_wiki_before')
|
tag = create_tag!(name: 'put_tag_wiki_before')
|
||||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body 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 {
|
expect {
|
||||||
put "/tags/#{ tag.id }", params: {
|
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)
|
.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(response).to have_http_status(:ok)
|
||||||
|
|
||||||
@@ -97,16 +98,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
|
|||||||
wiki_page.reload
|
wiki_page.reload
|
||||||
|
|
||||||
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after')
|
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
|
end
|
||||||
|
|
||||||
it 'does not record wiki_version when only category changes' do
|
it 'does not record wiki_version when only category changes' do
|
||||||
tag = create_tag!(name: 'put_tag_category_only')
|
tag = create_tag!(name: 'put_tag_category_only')
|
||||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
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 {
|
expect {
|
||||||
put "/tags/#{ tag.id }", params: {
|
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(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_category_only')
|
||||||
expect(tag.category).to eq('meme')
|
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
|
||||||
|
|
||||||
it 'does not record wiki_version when only aliases change' do
|
it 'does not record wiki_version when only aliases change' do
|
||||||
tag = create_tag!(name: 'put_tag_alias_only')
|
tag = create_tag!(name: 'put_tag_alias_only')
|
||||||
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
|
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 {
|
expect {
|
||||||
put "/tags/#{ tag.id }", params: {
|
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)
|
.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ require 'cgi'
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tags API', type: :request do
|
RSpec.describe 'Tags API', type: :request do
|
||||||
|
include WikiRecords
|
||||||
let!(:tn) { create(:tag_name, name: 'spec_tag') }
|
let!(:tn) { create(:tag_name, name: 'spec_tag') }
|
||||||
let!(:tag) { create(:tag, primary_tag_name: tn, category: :general) }
|
let!(:tag) { create(:tag, primary_tag_name: tn, category: :general) }
|
||||||
let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', tag:) }
|
let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', tag:) }
|
||||||
@@ -38,6 +39,22 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /tags' do
|
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
|
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)
|
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
|
it 'PATCH の名称変更で Wiki の本文履歴を追加しない' do
|
||||||
wiki_page =
|
wiki_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||||
body: 'wiki body before',
|
body: 'wiki body before',
|
||||||
created_by_user: member_user,
|
created_by_user: member_user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
|
|
||||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
patch "/tags/#{ tag.id }", params: {
|
patch "/tags/#{ tag.id }", params: {
|
||||||
@@ -743,23 +760,23 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.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(response).to have_http_status(:ok)
|
||||||
|
|
||||||
expect(wiki_page.reload.title).to eq('patch_wiki_renamed_tag')
|
expect(wiki_page.reload.tag.name('ja')).to eq('patch_wiki_renamed_tag')
|
||||||
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
|
end
|
||||||
|
|
||||||
it 'tag の category だけを変更しても wiki version は作成しない' do
|
it 'tag の category だけを変更しても wiki version は作成しない' do
|
||||||
wiki_page =
|
wiki_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||||
body: 'wiki body before',
|
body: 'wiki body before',
|
||||||
created_by_user: member_user,
|
created_by_user: member_user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
|
|
||||||
before_wiki_version_count = wiki_page.reload.wiki_versions.count
|
before_wiki_version_count = description_versions(wiki_page).count
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
patch "/tags/#{ tag.id }", params: {
|
patch "/tags/#{ tag.id }", params: {
|
||||||
@@ -768,7 +785,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
}.to change(TagVersion, :count).by(2)
|
}.to change(TagVersion, :count).by(2)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
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
|
end
|
||||||
|
|
||||||
it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do
|
it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do
|
||||||
@@ -799,7 +816,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
|
|
||||||
it '別 tag の正規名には変更できない' do
|
it '別 tag の正規名には変更できない' do
|
||||||
wiki_page =
|
wiki_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||||
body: 'patch collision wiki',
|
body: 'patch collision wiki',
|
||||||
created_by_user: member_user,
|
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(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))
|
expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name))
|
||||||
.to contain_exactly('unko')
|
.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
|
end
|
||||||
|
|
||||||
it 'system tag の name は変更できない' do
|
it 'system tag の name は変更できない' do
|
||||||
@@ -1193,7 +1210,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(old_name_alias).to have_attributes(
|
expect(old_name_alias).to have_attributes(
|
||||||
tag_id: tag.id, language_code: 'ja', primary_flg: false)
|
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),
|
expect(TagName.where(tag_id: tag.parents.select(:id),
|
||||||
language_code: 'ja', primary_flg: true).pluck(:name))
|
language_code: 'ja', primary_flg: true).pluck(:name))
|
||||||
@@ -1378,20 +1395,17 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
|
|
||||||
it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do
|
it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do
|
||||||
wiki_page =
|
wiki_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||||
body: 'wiki body before',
|
body: 'wiki body before',
|
||||||
created_by_user: member_user,
|
created_by_user: member_user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
|
|
||||||
Wiki::Commit.content!(
|
TagDescriptionOperation.update!(
|
||||||
page: wiki_page,
|
Locale.nipponese, tag: wiki_page.tag, body: 'wiki body before',
|
||||||
body: 'wiki body before',
|
updated_by_user: member_user, message: 'init')
|
||||||
created_user: member_user,
|
|
||||||
message: 'init'
|
|
||||||
)
|
|
||||||
|
|
||||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
put "/tags/#{ tag.id }", params: {
|
put "/tags/#{ tag.id }", params: {
|
||||||
@@ -1403,22 +1417,22 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.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(response).to have_http_status(:ok)
|
||||||
|
|
||||||
expect(wiki_page.reload.title).to eq('put_wiki_version_tag')
|
expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_version_tag')
|
||||||
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
|
end
|
||||||
|
|
||||||
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do
|
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do
|
||||||
wiki_page =
|
wiki_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||||
body: 'wiki body before',
|
body: 'wiki body before',
|
||||||
created_by_user: member_user,
|
created_by_user: member_user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
put "/tags/#{ tag.id }", params: {
|
put "/tags/#{ tag.id }", params: {
|
||||||
@@ -1438,20 +1452,19 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(3)
|
.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)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
tag.reload
|
tag.reload
|
||||||
|
|
||||||
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'))
|
||||||
expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b').wiki_page)
|
expect(wiki_page.reload.tag_id).to eq(tag.id)
|
||||||
.to be_nil
|
|
||||||
expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b'))
|
expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b'))
|
||||||
.to have_attributes(tag_id: tag.id, primary_flg: false)
|
.to have_attributes(tag_id: tag.id, primary_flg: false)
|
||||||
|
|
||||||
expect(wiki_page.title).to eq('spec_tag')
|
expect(wiki_page.tag.name('ja')).to eq('spec_tag')
|
||||||
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
|
end
|
||||||
|
|
||||||
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
|
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
|
||||||
@@ -1504,7 +1517,7 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
name: 'put_alias_collision_name',
|
name: 'put_alias_collision_name',
|
||||||
tag: old_owner)
|
tag: old_owner)
|
||||||
wiki_page =
|
wiki_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||||
body: 'put collision wiki',
|
body: 'put collision wiki',
|
||||||
created_by_user: member_user,
|
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(old_owner_versions.last.event_type).to eq('update')
|
||||||
expect(version_alias_names(old_owner_versions.last, 'ja'))
|
expect(version_alias_names(old_owner_versions.last, 'ja'))
|
||||||
.not_to include('put_alias_collision_name')
|
.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
|
end
|
||||||
|
|
||||||
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
||||||
@@ -1563,13 +1576,13 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
|
|
||||||
it 'Tag の名称変更で Wiki の本文履歴を追加しない' do
|
it 'Tag の名称変更で Wiki の本文履歴を追加しない' do
|
||||||
wiki_page =
|
wiki_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||||
body: 'wiki body before',
|
body: 'wiki body before',
|
||||||
created_by_user: member_user,
|
created_by_user: member_user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
|
|
||||||
wiki_history = wiki_page.wiki_versions.map(&:attributes)
|
wiki_history = description_versions(wiki_page).map(&:attributes)
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
put "/tags/#{ tag.id }", params: {
|
put "/tags/#{ tag.id }", params: {
|
||||||
@@ -1581,12 +1594,12 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.to change(TagVersion, :count).by(2)
|
.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(response).to have_http_status(:ok)
|
||||||
|
|
||||||
expect(wiki_page.reload.title).to eq('put_wiki_renamed_tag')
|
expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_renamed_tag')
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Wiki conflict handling', type: :request do
|
RSpec.describe 'Wiki conflict handling', type: :request do
|
||||||
|
include WikiRecords
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
def auth_headers 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
|
it 'returns 409 when base_revision_id is stale' do
|
||||||
page =
|
page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: create(:tag_name, name: 'wiki_conflict_request'),
|
tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'),
|
||||||
body: 'first',
|
body: 'first',
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
|
|
||||||
stale_id = page.current_revision.id
|
stale_id = current_description_version(page).id
|
||||||
|
|
||||||
Wiki::Commit.content!(
|
TagDescriptionOperation.update!(
|
||||||
page:,
|
Locale.nipponese,
|
||||||
|
tag: page.tag,
|
||||||
body: 'second',
|
body: 'second',
|
||||||
created_user: user,
|
updated_by_user: user,
|
||||||
message: 'other edit',
|
message: 'other edit',
|
||||||
base_revision_id: stale_id)
|
base_revision_id: stale_id)
|
||||||
|
|
||||||
put "/wiki/#{ page.id }",
|
put "/wiki/#{ page.id }",
|
||||||
params: {
|
params: { title: 'wiki_conflict_request',
|
||||||
title: 'wiki_conflict_request',
|
body: 'third',
|
||||||
body: 'third',
|
message: 'stale',
|
||||||
message: 'stale',
|
base_revision_id: stale_id },
|
||||||
base_revision_id: stale_id,
|
|
||||||
},
|
|
||||||
headers: auth_headers(user)
|
headers: auth_headers(user)
|
||||||
|
|
||||||
expect(response).to have_http_status(:conflict)
|
expect(response).to have_http_status(:conflict)
|
||||||
|
|
||||||
page.reload
|
page.reload
|
||||||
expect(page.body).to eq('second')
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Wiki creation freeze', type: :request do
|
||||||
|
include TagGroupRecords
|
||||||
|
include WikiRecords
|
||||||
|
|
||||||
|
let!(:user) { create_member_user! }
|
||||||
|
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||||
|
|
||||||
|
|
||||||
|
context 'existing description write protection' do
|
||||||
|
let!(:tag) { create(:tag, primary_name: 'protected_description') }
|
||||||
|
let!(:page) do
|
||||||
|
seed_description(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
[nil, :guest, :banned].each do |role|
|
||||||
|
it "rejects #{ role || 'anonymous' } without changing either history" do
|
||||||
|
actor = role && create(:user, role == :banned ? :member : role)
|
||||||
|
actor.update!(banned_at: Time.current) if role == :banned
|
||||||
|
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||||
|
before_state = group_state
|
||||||
|
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
|
||||||
|
headers: request_headers
|
||||||
|
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||||
|
expect(group_state).to eq(before_state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects banned IPs without writes' do
|
||||||
|
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||||
|
before_state = group_state
|
||||||
|
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' }, headers: headers
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
expect(group_state).to eq(before_state)
|
||||||
|
end
|
||||||
|
|
||||||
|
['', "\r\n\r\n"].each do |body|
|
||||||
|
it "rejects invalid description body #{ body.inspect } atomically" do
|
||||||
|
before_state = group_state
|
||||||
|
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: }, headers: headers
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(group_state).to eq(before_state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
require 'cgi'
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe 'Wiki history integrity', type: :request do
|
|
||||||
let!(:user) { create_member_user! }
|
|
||||||
|
|
||||||
def auth_headers user
|
|
||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
|
|
||||||
Wiki::Commit.create_content!(
|
|
||||||
tag_name: create(:tag_name, name: title),
|
|
||||||
body:,
|
|
||||||
created_by_user: user,
|
|
||||||
message:)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'POST /wiki' do
|
|
||||||
it 'creates wiki_page, wiki_revision, and wiki_version atomically' do
|
|
||||||
expect {
|
|
||||||
post '/wiki',
|
|
||||||
params: {
|
|
||||||
title: 'Help:wiki_history_create_atomic',
|
|
||||||
body: "a\nb\nc",
|
|
||||||
message: 'initial commit',
|
|
||||||
},
|
|
||||||
headers: auth_headers(user)
|
|
||||||
}
|
|
||||||
.to change(WikiPage, :count).by(1)
|
|
||||||
.and change(WikiRevision, :count).by(1)
|
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
page = WikiPage.find(json.fetch('id'))
|
|
||||||
revision = page.current_revision
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(page.title).to eq('Help:wiki_history_create_atomic')
|
|
||||||
expect(page.body).to eq("a\nb\nc")
|
|
||||||
|
|
||||||
expect(revision).to be_content
|
|
||||||
expect(revision.message).to eq('initial commit')
|
|
||||||
expect(revision.lines_count).to eq(3)
|
|
||||||
|
|
||||||
expect(version).to have_attributes(
|
|
||||||
version_no: 1,
|
|
||||||
event_type: 'create',
|
|
||||||
title: 'Help:wiki_history_create_atomic',
|
|
||||||
body: "a\nb\nc",
|
|
||||||
reason: 'initial commit',
|
|
||||||
created_by_user_id: user.id
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 422 and creates nothing when normalised body is blank' do
|
|
||||||
expect {
|
|
||||||
post '/wiki',
|
|
||||||
params: {
|
|
||||||
title: 'Help:wiki_history_blank_body',
|
|
||||||
body: "\r\n\r\n",
|
|
||||||
message: 'blank',
|
|
||||||
},
|
|
||||||
headers: auth_headers(user)
|
|
||||||
}
|
|
||||||
.not_to change(WikiPage, :count)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(WikiPage.joins(:tag_name)
|
|
||||||
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 422 and creates no partial page when title already exists' do
|
|
||||||
create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first')
|
|
||||||
|
|
||||||
expect {
|
|
||||||
post '/wiki',
|
|
||||||
params: {
|
|
||||||
title: 'Help:wiki_history_duplicate_title',
|
|
||||||
body: 'second',
|
|
||||||
message: 'duplicate',
|
|
||||||
},
|
|
||||||
headers: auth_headers(user)
|
|
||||||
}
|
|
||||||
.not_to change(WikiPage, :count)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(WikiPage.joins(:tag_name)
|
|
||||||
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'PUT /wiki/:id' do
|
|
||||||
it 'updates body and records wiki_revision and wiki_version' do
|
|
||||||
page = create_wiki_page(title: 'wiki_history_update_body', body: 'before')
|
|
||||||
current_id = page.current_revision.id
|
|
||||||
|
|
||||||
expect {
|
|
||||||
put "/wiki/#{ page.id }",
|
|
||||||
params: {
|
|
||||||
title: 'wiki_history_update_body',
|
|
||||||
body: 'after',
|
|
||||||
message: 'edit body',
|
|
||||||
base_revision_id: current_id,
|
|
||||||
},
|
|
||||||
headers: auth_headers(user)
|
|
||||||
}
|
|
||||||
.to change(WikiRevision, :count).by(1)
|
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
page.reload
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(page.title).to eq('wiki_history_update_body')
|
|
||||||
expect(page.body).to eq('after')
|
|
||||||
|
|
||||||
expect(version).to have_attributes(
|
|
||||||
event_type: 'update',
|
|
||||||
title: 'wiki_history_update_body',
|
|
||||||
body: 'after',
|
|
||||||
reason: 'edit body',
|
|
||||||
created_by_user_id: user.id
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'renames title and records wiki_version with new title' do
|
|
||||||
page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before')
|
|
||||||
current_id = page.current_revision.id
|
|
||||||
|
|
||||||
expect {
|
|
||||||
put "/wiki/#{ page.id }",
|
|
||||||
params: {
|
|
||||||
title: 'wiki_history_rename_after',
|
|
||||||
body: 'after',
|
|
||||||
message: 'rename',
|
|
||||||
base_revision_id: current_id,
|
|
||||||
},
|
|
||||||
headers: auth_headers(user)
|
|
||||||
}
|
|
||||||
.to change(WikiRevision, :count).by(1)
|
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
page.reload
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(page.title).to eq('wiki_history_rename_after')
|
|
||||||
expect(page.body).to eq('after')
|
|
||||||
|
|
||||||
expect(version).to have_attributes(
|
|
||||||
event_type: 'update',
|
|
||||||
title: 'wiki_history_rename_after',
|
|
||||||
body: 'after',
|
|
||||||
reason: 'rename',
|
|
||||||
created_by_user_id: user.id
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not change title, body, revision, or version on stale base_revision_id' do
|
|
||||||
page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first')
|
|
||||||
stale_id = page.current_revision.id
|
|
||||||
|
|
||||||
Wiki::Commit.content!(
|
|
||||||
page:,
|
|
||||||
body: 'second',
|
|
||||||
created_user: user,
|
|
||||||
message: 'other edit',
|
|
||||||
base_revision_id: stale_id)
|
|
||||||
|
|
||||||
page.reload
|
|
||||||
current_title = page.title
|
|
||||||
current_body = page.body
|
|
||||||
revision_count = page.wiki_revisions.count
|
|
||||||
version_count = page.wiki_versions.count
|
|
||||||
|
|
||||||
put "/wiki/#{ page.id }",
|
|
||||||
params: {
|
|
||||||
title: 'wiki_history_conflict_renamed',
|
|
||||||
body: 'third',
|
|
||||||
message: 'stale edit',
|
|
||||||
base_revision_id: stale_id,
|
|
||||||
},
|
|
||||||
headers: auth_headers(user)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:conflict)
|
|
||||||
|
|
||||||
page.reload
|
|
||||||
expect(page.title).to eq(current_title)
|
|
||||||
expect(page.body).to eq(current_body)
|
|
||||||
expect(page.wiki_revisions.count).to eq(revision_count)
|
|
||||||
expect(page.wiki_versions.count).to eq(version_count)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Wiki resource ordering', type: :request do
|
||||||
|
include TagGroupRecords
|
||||||
|
let(:user) { create_member_user! }
|
||||||
|
|
||||||
|
it 'selects the latest twenty across both resources before applying the global limit' do
|
||||||
|
start = Time.utc(2026, 1, 1)
|
||||||
|
descriptions = 24.times.map do |index|
|
||||||
|
tag = create(:tag, primary_name: "rank_#{ index }")
|
||||||
|
TagDescription.create!(tag:, language_code: 'ja', body: 'current', version_no: 1,
|
||||||
|
updated_at: start + index.hours)
|
||||||
|
end
|
||||||
|
groups = [24, 25].map do |hour|
|
||||||
|
group = TagGroup.find(seed_group(name: "rank_group_#{ hour }"))
|
||||||
|
group.update_columns(updated_at: start + hour.hours)
|
||||||
|
group
|
||||||
|
end
|
||||||
|
|
||||||
|
get '/wiki', params: { title: 'rank' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expected = groups.reverse.map { ['tag_group', _1.id] } +
|
||||||
|
descriptions.last(18).reverse.map { ['tag_description', _1.id] }
|
||||||
|
expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] }).to eq(expected)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'breaks timestamp ties by resource kind and ID while retaining legacy overlaps' do
|
||||||
|
time = Time.utc(2026, 1, 1)
|
||||||
|
tag = create(:tag, primary_name: 'tied')
|
||||||
|
description = TagDescription.create!(tag:, language_code: 'ja', body: 'current',
|
||||||
|
version_no: 1, updated_at: time)
|
||||||
|
group = TagGroup.find(seed_group(name: 'tied', id: description.id))
|
||||||
|
group.update_columns(updated_at: time)
|
||||||
|
|
||||||
|
2.times do
|
||||||
|
get '/wiki', params: { title: 'tied' }
|
||||||
|
expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] })
|
||||||
|
.to eq([['tag_description', description.id], ['tag_group', description.id]])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||||
|
include WikiRecords
|
||||||
|
include TagGroupRecords
|
||||||
|
include_context 'Ainu locale contract'
|
||||||
|
|
||||||
|
let!(:user) { create_member_user! }
|
||||||
|
let!(:tag) { create(:tag, primary_name: 'japanese_title') }
|
||||||
|
let!(:ainu_name) { add_ainu_name(tag, 'ainu_title') }
|
||||||
|
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||||
|
let!(:japanese) do
|
||||||
|
TagDescriptionOperation.create!(
|
||||||
|
Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user)
|
||||||
|
end
|
||||||
|
let!(:ainu) do
|
||||||
|
TagDescriptionOperation.create!(
|
||||||
|
ainu_locale, tag:, body: 'Ainu body', created_by_user: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
['/wiki', '/wiki/search'].each do |path|
|
||||||
|
it "filters #{ path } by the selected language" do
|
||||||
|
get path, params: { locale: 'ain' }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { _1.fetch('id') }).to eq([ainu.id])
|
||||||
|
expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body')
|
||||||
|
|
||||||
|
get path, params: { locale: 'ain', title: 'japanese' }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the existing default locale when no locale is specified' do
|
||||||
|
get '/wiki'
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { _1.fetch('id') }).to eq([japanese.id])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'resolves titles and aliases only in the selected language in both APIs' do
|
||||||
|
['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path|
|
||||||
|
get path, params: { locale: 'ain' }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path|
|
||||||
|
get path, params: { locale: 'ain' }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('body')).to eq('Ainu body')
|
||||||
|
end
|
||||||
|
get '/wiki/title/ainu_title/exists', params: { locale: 'ain' }
|
||||||
|
expect(response).to have_http_status(:no_content)
|
||||||
|
get '/wiki/title/japanese_title/exists', params: { locale: 'ain' }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not disclose another language through ID, existence, diff or history' do
|
||||||
|
revision = current_description_version(japanese)
|
||||||
|
["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists",
|
||||||
|
"/wiki/#{ japanese.id }/diff"].each do |path|
|
||||||
|
get path, params: { locale: 'ain', from: revision.id }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
expect(response.body).not_to include('Japanese body')
|
||||||
|
end
|
||||||
|
get '/wiki/changes', params: { locale: 'ain', id: japanese.id }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to eq([])
|
||||||
|
get '/wiki/changes', params: { locale: 'ain' }
|
||||||
|
expect(json.map { _1.fetch('revision_id') })
|
||||||
|
.to eq([current_description_version(ainu).id])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not update a description addressed through another language ID' do
|
||||||
|
snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes]
|
||||||
|
expect do
|
||||||
|
put "/wiki/#{ japanese.id }", params: {
|
||||||
|
locale: 'ain', title: 'renamed', body: 'wrong target' }, headers: headers
|
||||||
|
end.not_to change(TagDescriptionVersion, :count)
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes])
|
||||||
|
.to eq(snapshot)
|
||||||
|
end
|
||||||
|
|
||||||
|
[:wiki, :native].each do |api|
|
||||||
|
it "creates the selected language through #{ api } without changing Japanese history" do
|
||||||
|
other = create(:tag, primary_name: 'other_japanese')
|
||||||
|
add_ainu_name(other, 'other_ainu')
|
||||||
|
original = TagDescriptionOperation.create!(
|
||||||
|
Locale.nipponese, tag: other, body: 'original', created_by_user: user)
|
||||||
|
snapshot = original.attributes
|
||||||
|
expect do
|
||||||
|
if api == :wiki
|
||||||
|
post '/wiki', params: { locale: 'ain', title: 'other_ainu', body: 'created' },
|
||||||
|
headers: headers
|
||||||
|
else
|
||||||
|
put "/tags/#{ other.id }/description", params: { locale: 'ain', body: 'created' },
|
||||||
|
headers: headers
|
||||||
|
end
|
||||||
|
end.to change(TagDescription, :count).by(1)
|
||||||
|
.and change(TagDescriptionVersion, :count).by(1)
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
created = TagDescription.find_by!(tag: other, language_code: 'ain')
|
||||||
|
expect(created).to have_attributes(body: 'created', version_no: 1)
|
||||||
|
expect(current_description_version(created)).to have_attributes(language_code: 'ain')
|
||||||
|
expect(original.reload.attributes).to eq(snapshot)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shares selected-language edits and revision identity across both APIs' do
|
||||||
|
snapshot = japanese.attributes
|
||||||
|
first = current_description_version(ainu)
|
||||||
|
put "/tags/#{ tag.id }/description", params: { locale: 'ain', body: 'native edit' },
|
||||||
|
headers: headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
second = current_description_version(ainu)
|
||||||
|
expect(second).to have_attributes(language_code: 'ain', version_no: 2)
|
||||||
|
expect(second.id).not_to eq(first.id)
|
||||||
|
get "/wiki/#{ ainu.id }", params: { locale: 'ain' }
|
||||||
|
expect(json).to include('body' => 'native edit', 'revision_id' => second.id)
|
||||||
|
|
||||||
|
put "/wiki/#{ ainu.id }", params: {
|
||||||
|
locale: 'ain', title: 'ainu_title', body: 'facade edit',
|
||||||
|
base_revision_id: second.id }, headers: headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
get "/tags/#{ tag.id }/description", params: { locale: 'ain' }
|
||||||
|
expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3)
|
||||||
|
expect(japanese.reload.attributes).to eq(snapshot)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects a stale selected-language revision without a partial title or body edit' do
|
||||||
|
stale = current_description_version(ainu)
|
||||||
|
TagDescriptionOperation.update!(
|
||||||
|
ainu_locale, tag:, body: 'current', updated_by_user: user)
|
||||||
|
snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes]
|
||||||
|
expect do
|
||||||
|
put "/wiki/#{ ainu.id }", params: {
|
||||||
|
locale: 'ain', title: 'conflicting_name', body: 'current',
|
||||||
|
base_revision_id: stale.id }, headers: headers
|
||||||
|
end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0)
|
||||||
|
expect(response).to have_http_status(:conflict)
|
||||||
|
expect([ainu.reload.attributes, ainu_name.reload.attributes, japanese.reload.attributes])
|
||||||
|
.to eq(snapshot)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'scopes historical show and diff to the selected description' do
|
||||||
|
first = current_description_version(ainu)
|
||||||
|
TagDescriptionOperation.update!(
|
||||||
|
ainu_locale, tag:, body: 'later', updated_by_user: user)
|
||||||
|
get "/wiki/#{ ainu.id }", params: { locale: 'ain', version: first.id }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id)
|
||||||
|
get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain', from: first.id }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id)
|
||||||
|
get "/wiki/#{ ainu.id }/diff", params: {
|
||||||
|
locale: 'ain', from: current_description_version(japanese).id }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,7 @@ require 'securerandom'
|
|||||||
|
|
||||||
|
|
||||||
RSpec.describe 'Wiki API', type: :request do
|
RSpec.describe 'Wiki API', type: :request do
|
||||||
|
include WikiRecords
|
||||||
def auth_headers(user)
|
def auth_headers(user)
|
||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
end
|
end
|
||||||
@@ -18,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
deprecated_at: Time.zone.local(2026, 6, 1))
|
deprecated_at: Time.zone.local(2026, 6, 1))
|
||||||
end
|
end
|
||||||
let!(:page) do
|
let!(:page) do
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: tn,
|
tag_name: tn,
|
||||||
body: 'init',
|
body: 'init',
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
@@ -108,94 +109,14 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when success' do
|
['Help:TestPage', 'ヘルプ:ホーム'].each do |title|
|
||||||
it 'creates wiki_page and first content revision' do
|
it "rejects frozen creation of #{ title } without history writes" do
|
||||||
expect do
|
expect do
|
||||||
post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' },
|
post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member)
|
||||||
headers: auth_headers(member)
|
end.to change(TagDescription, :count).by(0)
|
||||||
end
|
.and change(TagDescriptionVersion, :count).by(0)
|
||||||
.to change(WikiPage, :count).by(1)
|
|
||||||
.and change(WikiRevision, :count).by(1)
|
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
expect(response.status).to be_between(400, 499)
|
||||||
|
|
||||||
page_id = json.fetch('id')
|
|
||||||
expect(json.fetch('title')).to eq('Help:TestPage')
|
|
||||||
|
|
||||||
created_page = WikiPage.find(page_id)
|
|
||||||
version = created_page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(version).to have_attributes(
|
|
||||||
version_no: 1,
|
|
||||||
event_type: 'create',
|
|
||||||
title: 'Help:TestPage',
|
|
||||||
body: "a\nb\nc",
|
|
||||||
created_by_user_id: member.id
|
|
||||||
)
|
|
||||||
|
|
||||||
rev = created_page.current_revision
|
|
||||||
expect(rev).to be_present
|
|
||||||
expect(rev).to be_content
|
|
||||||
expect(rev.message).to eq('init')
|
|
||||||
|
|
||||||
expect(created_page.body).to eq("a\nb\nc")
|
|
||||||
|
|
||||||
expect(rev.lines_count).to eq(3)
|
|
||||||
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
|
|
||||||
expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c'])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'reuses existing WikiLine rows by sha256' do
|
|
||||||
# 先に同じ行を作っておく
|
|
||||||
WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current)
|
|
||||||
|
|
||||||
post endpoint,
|
|
||||||
params: { title: 'Help:Reuse', body: "a\na" },
|
|
||||||
headers: auth_headers(member)
|
|
||||||
|
|
||||||
page = WikiPage.find(JSON.parse(response.body).fetch('id'))
|
|
||||||
rev = page.current_revision
|
|
||||||
expect(rev.lines_count).to eq(2)
|
|
||||||
|
|
||||||
# "a" の WikiLine が増殖しない(1行のはず)
|
|
||||||
expect(WikiLine.where(body: 'a').count).to eq(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'deduplicates duplicated new lines before upsert' do
|
|
||||||
duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
|
|
||||||
|
|
||||||
post endpoint,
|
|
||||||
params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
|
|
||||||
headers: auth_headers(member)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
page = WikiPage.find(json.fetch('id'))
|
|
||||||
rev = page.current_revision
|
|
||||||
|
|
||||||
expect(rev.lines_count).to eq(2)
|
|
||||||
expect(WikiLine.where(body: duplicated).count).to eq(1)
|
|
||||||
expect(rev.wiki_revision_lines.count).to eq(2)
|
|
||||||
expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'normalises CRLF and strips trailing newlines' do
|
|
||||||
post endpoint,
|
|
||||||
params: { title: 'Help:NormalisedBody',
|
|
||||||
body: "a\r\nb\r\n\r\n", message: 'normalise' },
|
|
||||||
headers: auth_headers(member)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
page = WikiPage.find(json.fetch('id'))
|
|
||||||
rev = page.current_revision
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(page.body).to eq("a\nb")
|
|
||||||
expect(version.body).to eq("a\nb")
|
|
||||||
expect(rev.lines_count).to eq(2)
|
|
||||||
expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -208,10 +129,10 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:test_tag_name) { create(:tag_name, name: 'TestPage') }
|
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
|
||||||
|
|
||||||
let!(:page) do
|
let!(:page) do
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: test_tag_name,
|
tag_name: test_tag_name,
|
||||||
body: "a\nb",
|
body: "a\nb",
|
||||||
created_by_user: member,
|
created_by_user: member,
|
||||||
@@ -245,33 +166,30 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
context 'when success' do
|
context 'when success' do
|
||||||
it 'creates new revision and returns 200' do
|
it 'creates new revision and returns 200' do
|
||||||
current_id = page.wiki_revisions.maximum(:id)
|
current_id = description_versions(page).maximum(:id)
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
put "/wiki/#{page.id}",
|
put "/wiki/#{page.id}",
|
||||||
params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
|
params: { title: 'TestPage', body: "x\ny", message: 'edit',
|
||||||
|
base_revision_id: current_id },
|
||||||
headers: auth_headers(member)
|
headers: auth_headers(member)
|
||||||
end
|
end
|
||||||
.to change(WikiRevision, :count).by(1)
|
.to change(TagDescriptionVersion, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
version = description_versions(page).order(:version_no).last
|
||||||
|
|
||||||
expect(version).to have_attributes(
|
expect(version).to have_attributes(
|
||||||
event_type: 'update',
|
event_type: 'update',
|
||||||
title: 'TestPage',
|
|
||||||
body: "x\ny",
|
body: "x\ny",
|
||||||
created_by_user_id: member.id
|
created_by_user_id: member.id)
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
page.reload
|
page.reload
|
||||||
rev = page.current_revision
|
rev = current_description_version(page)
|
||||||
expect(rev).to be_content
|
|
||||||
expect(rev.message).to eq('edit')
|
|
||||||
expect(page.body).to eq("x\ny")
|
expect(page.body).to eq("x\ny")
|
||||||
expect(rev.base_revision_id).to eq(current_id)
|
expect(rev.version_no).to eq(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'wiki body だけを変更しても tag version は作成しない' do
|
it 'wiki body だけを変更しても tag version は作成しない' do
|
||||||
@@ -284,13 +202,13 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
created_by_user: member)
|
created_by_user: member)
|
||||||
|
|
||||||
linked_page =
|
linked_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: linked_tag_name,
|
tag_name: linked_tag_name,
|
||||||
body: 'before',
|
body: 'before',
|
||||||
created_by_user: member,
|
created_by_user: member,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
|
|
||||||
current_id = linked_page.current_revision.id
|
current_id = current_description_version(linked_page).id
|
||||||
before_count = linked_tag.reload.tag_versions.count
|
before_count = linked_tag.reload.tag_versions.count
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
@@ -303,8 +221,8 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
},
|
},
|
||||||
headers: auth_headers(member)
|
headers: auth_headers(member)
|
||||||
}
|
}
|
||||||
.to change(WikiRevision, :count).by(1)
|
.to change(TagDescriptionVersion, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(linked_tag.reload.tag_versions.count).to eq(before_count)
|
expect(linked_tag.reload.tag_versions.count).to eq(before_count)
|
||||||
@@ -314,10 +232,12 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
context 'when conflict' do
|
context 'when conflict' do
|
||||||
it 'returns 409 when base_revision_id mismatches' do
|
it 'returns 409 when base_revision_id mismatches' do
|
||||||
# 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める
|
# 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める
|
||||||
Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit')
|
TagDescriptionOperation.update!(
|
||||||
|
Locale.nipponese, tag: page.tag, body: 'zzz',
|
||||||
|
updated_by_user: member, message: 'other edit')
|
||||||
page.reload
|
page.reload
|
||||||
|
|
||||||
stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id
|
stale_id = description_versions(page).order(:id).first.id # わざと古い id
|
||||||
put "/wiki/#{page.id}",
|
put "/wiki/#{page.id}",
|
||||||
params: { title: 'TestPage', body: 'x', base_revision_id: stale_id },
|
params: { title: 'TestPage', body: 'x', base_revision_id: stale_id },
|
||||||
headers: auth_headers(member)
|
headers: auth_headers(member)
|
||||||
@@ -358,13 +278,14 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
describe 'GET /wiki/search' do
|
describe 'GET /wiki/search' do
|
||||||
before do
|
before do
|
||||||
Wiki::Commit.create_content!(
|
search_tag = create(:tag, primary_name: 'spec_wiki_title_2')
|
||||||
tag_name: create(:tag_name, name: 'spec_wiki_title_2'),
|
seed_description(
|
||||||
|
tag_name: search_tag.tag_name('ja'),
|
||||||
body: 'search body 2',
|
body: 'search body 2',
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
|
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: create(:tag_name, name: 'unrelated_title'),
|
tag_name: create(:tag_name, name: 'unrelated_title'),
|
||||||
body: 'unrelated body',
|
body: 'unrelated body',
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
@@ -395,13 +316,15 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
describe 'GET /wiki/changes' do
|
describe 'GET /wiki/changes' do
|
||||||
let!(:rev1) do
|
let!(:rev1) do
|
||||||
Wiki::Commit.content!(page: page, body: "a\nb", created_user: user, message: 'r1')
|
TagDescriptionOperation.update!(
|
||||||
page.current_revision
|
Locale.nipponese, tag: page.tag, body: "a\nb", updated_by_user: user, message: 'r1')
|
||||||
|
current_description_version(page)
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:rev2) do
|
let!(:rev2) do
|
||||||
Wiki::Commit.content!(page: page, body: "a\nc", created_user: user, message: 'r2')
|
TagDescriptionOperation.update!(
|
||||||
page.current_revision
|
Locale.nipponese, tag: page.tag, body: "a\nc", updated_by_user: user, message: 'r2')
|
||||||
|
current_description_version(page)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns latest revisions (optionally filtered by page id)' do
|
it 'returns latest revisions (optionally filtered by page id)' do
|
||||||
@@ -435,12 +358,9 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
it 'returns empty array when page has no revisions and filtered by id' do
|
it 'returns empty array when page has no revisions and filtered by id' do
|
||||||
# 別ページを作って revision 無し
|
# 別ページを作って revision 無し
|
||||||
tn2 = create(:tag_name, name: 'spec_no_rev')
|
tn2 = create(:tag_name, name: 'spec_no_rev')
|
||||||
# 異常データ: revision 無し WikiPage を直接作る
|
# 異常データ: Version のない TagDescription を直接作る
|
||||||
p2 = WikiPage.create!(
|
p2 = TagDescription.create!(
|
||||||
tag_name: tn2,
|
tag: tn2.tag, language_code: 'ja', body: 'init', version_no: 1)
|
||||||
body: 'init',
|
|
||||||
created_user: user,
|
|
||||||
updated_user: user)
|
|
||||||
|
|
||||||
get "/wiki/changes?id=#{p2.id}"
|
get "/wiki/changes?id=#{p2.id}"
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -476,13 +396,15 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
describe 'GET /wiki/:id/diff' do
|
describe 'GET /wiki/:id/diff' do
|
||||||
let!(:rev_a) do
|
let!(:rev_a) do
|
||||||
Wiki::Commit.content!(page: page, body: "a\nb\nc", created_user: user, message: 'A')
|
TagDescriptionOperation.update!(
|
||||||
page.current_revision
|
Locale.nipponese, tag: page.tag, body: "a\nb\nc", updated_by_user: user, message: 'A')
|
||||||
|
current_description_version(page)
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:rev_b) do
|
let!(:rev_b) do
|
||||||
Wiki::Commit.content!(page: page, body: "a\nx\nc", created_user: user, message: 'B')
|
TagDescriptionOperation.update!(
|
||||||
page.current_revision
|
Locale.nipponese, tag: page.tag, body: "a\nx\nc", updated_by_user: user, message: 'B')
|
||||||
|
current_description_version(page)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns diff json between revisions' do
|
it 'returns diff json between revisions' do
|
||||||
@@ -508,44 +430,23 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json['older_revision_id']).to eq(rev_a.id)
|
expect(json['older_revision_id']).to eq(rev_a.id)
|
||||||
expect(json['newer_revision_id']).to eq(page.current_revision.id)
|
expect(json['newer_revision_id']).to eq(current_description_version(page).id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'Wiki::Commit.redirect!' do
|
|
||||||
it 'raises because redirect revisions are deprecated' do
|
|
||||||
target_tag_name = create(:tag_name, name: 'redirect_deprecated_target')
|
|
||||||
target =
|
|
||||||
Wiki::Commit.create_content!(
|
|
||||||
tag_name: target_tag_name,
|
|
||||||
body: 'target',
|
|
||||||
created_by_user: user,
|
|
||||||
message: 'init')
|
|
||||||
|
|
||||||
expect {
|
|
||||||
Wiki::Commit.redirect!(
|
|
||||||
page: page,
|
|
||||||
redirect_page: target,
|
|
||||||
created_user: user,
|
|
||||||
message: 'redirect',
|
|
||||||
base_revision_id: page.current_revision.id
|
|
||||||
)
|
|
||||||
}.to raise_error(RuntimeError, '廃止しました.')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'wiki title を変更すると対応する tag の version を作成する' do
|
it 'wiki title を変更すると対応する tag の version を作成する' do
|
||||||
linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version')
|
linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version')
|
||||||
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general)
|
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general)
|
||||||
|
|
||||||
linked_page =
|
linked_page =
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: linked_tag_name,
|
tag_name: linked_tag_name,
|
||||||
body: 'before',
|
body: 'before',
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
|
|
||||||
current_id = linked_page.current_revision.id
|
current_id = current_description_version(linked_page).id
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
put "/wiki/#{ linked_page.id }",
|
put "/wiki/#{ linked_page.id }",
|
||||||
@@ -557,8 +458,7 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
},
|
},
|
||||||
headers: auth_headers(user)
|
headers: auth_headers(user)
|
||||||
}
|
}
|
||||||
.to change(WikiRevision, :count).by(1)
|
.to change(TagDescriptionVersion, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
.and change { linked_tag.reload.tag_versions.count }.by(2)
|
.and change { linked_tag.reload.tag_versions.count }.by(2)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Wiki title collision', type: :request do
|
RSpec.describe 'Wiki title collision', type: :request do
|
||||||
|
include WikiRecords
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
|
|
||||||
def auth_headers user
|
def auth_headers user
|
||||||
@@ -8,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_wiki_page title:, body:
|
def create_wiki_page title:, body:
|
||||||
Wiki::Commit.create_content!(
|
seed_description(
|
||||||
tag_name: create(:tag_name, name: title),
|
tag_name: create(:tag_name, name: title),
|
||||||
body:,
|
body:,
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
@@ -19,9 +20,9 @@ RSpec.describe 'Wiki title collision', type: :request do
|
|||||||
source = create_wiki_page(title: 'wiki_collision_source', body: 'source body')
|
source = create_wiki_page(title: 'wiki_collision_source', body: 'source body')
|
||||||
create_wiki_page(title: 'wiki_collision_target', body: 'target body')
|
create_wiki_page(title: 'wiki_collision_target', body: 'target body')
|
||||||
|
|
||||||
source_revision_count = source.wiki_revisions.count
|
source_revision_count = description_versions(source).count
|
||||||
source_version_count = source.wiki_versions.count
|
source_version_count = description_versions(source).count
|
||||||
old_title = source.title
|
old_title = source.tag.reload.name('ja')
|
||||||
old_body = source.body
|
old_body = source.body
|
||||||
|
|
||||||
put "/wiki/#{ source.id }",
|
put "/wiki/#{ source.id }",
|
||||||
@@ -29,7 +30,7 @@ RSpec.describe 'Wiki title collision', type: :request do
|
|||||||
title: 'wiki_collision_target',
|
title: 'wiki_collision_target',
|
||||||
body: 'new body',
|
body: 'new body',
|
||||||
message: 'rename collision',
|
message: 'rename collision',
|
||||||
base_revision_id: source.current_revision.id,
|
base_revision_id: current_description_version(source).id,
|
||||||
},
|
},
|
||||||
headers: auth_headers(user)
|
headers: auth_headers(user)
|
||||||
|
|
||||||
@@ -37,26 +38,23 @@ RSpec.describe 'Wiki title collision', type: :request do
|
|||||||
|
|
||||||
source.reload
|
source.reload
|
||||||
|
|
||||||
expect(source.title).to eq(old_title)
|
expect(source.tag.reload.name('ja')).to eq(old_title)
|
||||||
expect(source.body).to eq(old_body)
|
expect(source.body).to eq(old_body)
|
||||||
expect(source.wiki_revisions.count).to eq(source_revision_count)
|
expect(description_versions(source).count).to eq(source_revision_count)
|
||||||
expect(source.wiki_versions.count).to eq(source_version_count)
|
expect(description_versions(source).count).to eq(source_version_count)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 422 when creating wiki with existing title' do
|
it 'rejects frozen creation with an existing title without adding history' do
|
||||||
create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists')
|
create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists')
|
||||||
|
|
||||||
expect {
|
expect do
|
||||||
post '/wiki',
|
post '/wiki',
|
||||||
params: {
|
params: { title: 'Help:wiki_collision_create', body: 'new body',
|
||||||
title: 'Help:wiki_collision_create',
|
message: 'duplicate create' },
|
||||||
body: 'new body',
|
|
||||||
message: 'duplicate create',
|
|
||||||
},
|
|
||||||
headers: auth_headers(user)
|
headers: auth_headers(user)
|
||||||
}
|
end.to change(TagDescription, :count).by(0)
|
||||||
.not_to change(WikiPage, :count)
|
.and change(TagDescriptionVersion, :count).by(0)
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response.status).to be_between(400, 499)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,115 +1,25 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
|
RSpec.describe 'Removed standalone Wiki resources', type: :request do
|
||||||
let!(:user) { create_member_user! }
|
include TagGroupRecords
|
||||||
let!(:tag_name) { create(:tag_name, name: 'Help_contract') }
|
let(:user) { create_member_user! }
|
||||||
let!(:page) do
|
|
||||||
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user)
|
|
||||||
end
|
|
||||||
let!(:first_revision) { page.current_revision }
|
|
||||||
let!(:last_revision) do
|
|
||||||
Wiki::Commit.content!(page:, body: 'after', created_user: user,
|
|
||||||
message: 'legacy edit', base_revision_id: first_revision.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
before { expect(TagDescription.exists?(id: page.id)).to be(false) }
|
['Help:old', 'ヘルプ:旧ページ'].each do |title|
|
||||||
|
it "does not resolve #{ title } as a Wiki resource" do
|
||||||
it 'keeps a Tag-linked legacy page in the legacy domain when its title changes' do
|
path = "/wiki/title/#{ CGI.escapeURIComponent(title) }"
|
||||||
create(:tag, primary_tag_name: tag_name)
|
without_group_legacy_reads { get path }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
expect do
|
get "#{ path }/exists"
|
||||||
put "/wiki/#{ page.id }",
|
|
||||||
params: { title: 'legacy_tag_renamed', body: 'changed legacy body',
|
|
||||||
message: 'legacy rename', base_revision_id: last_revision.id },
|
|
||||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
|
||||||
end.to change(WikiRevision, :count).by(1)
|
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
.and change(TagDescription, :count).by(0)
|
|
||||||
.and change(TagDescriptionVersion, :count).by(0)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(page.reload).to have_attributes(body: 'changed legacy body')
|
|
||||||
expect(page.title).to eq('legacy_tag_renamed')
|
|
||||||
expect(page.current_revision).to have_attributes(message: 'legacy rename')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'shows legacy current and historical content by ID' do
|
|
||||||
get "/wiki/#{ page.id }"
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id,
|
|
||||||
'pred' => first_revision.id, 'succ' => nil)
|
|
||||||
get "/wiki/#{ page.id }", params: { version: first_revision.id }
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id,
|
|
||||||
'pred' => nil, 'succ' => last_revision.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'shows legacy current and historical content by title' do
|
|
||||||
get "/wiki/title/#{ tag_name.name }"
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id)
|
|
||||||
get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id }
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'recognises legacy IDs' do
|
|
||||||
get "/wiki/#{ page.id }/exists"
|
|
||||||
expect(response).to have_http_status(:no_content)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'recognises legacy titles' do
|
|
||||||
get "/wiki/title/#{ tag_name.name }/exists"
|
|
||||||
expect(response).to have_http_status(:no_content)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'diffs legacy revisions with explicit and current target' do
|
|
||||||
[last_revision.id, nil].each do |to|
|
|
||||||
get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: }
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json).to include(
|
|
||||||
'wiki_page_id' => page.id, 'title' => tag_name.name,
|
|
||||||
'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id,
|
|
||||||
'diff' => [{ 'type' => 'removed', 'content' => 'before' },
|
|
||||||
{ 'type' => 'added', 'content' => 'after' }])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'revision resource scope' do
|
|
||||||
let!(:other_page) do
|
|
||||||
Wiki::Commit.create_content!(
|
|
||||||
tag_name: create(:tag_name, name: 'Help_other_contract'),
|
|
||||||
body: 'other page history', created_by_user: user)
|
|
||||||
end
|
|
||||||
let(:other_revision) { other_page.current_revision }
|
|
||||||
|
|
||||||
it 'returns 404 when show specifies another page revision' do
|
|
||||||
get "/wiki/#{ page.id }", params: { version: other_revision.id }
|
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 404 when diff from belongs to another page' do
|
it "rejects standalone creation of #{ title } without writes" do
|
||||||
get "/wiki/#{ page.id }/diff",
|
user
|
||||||
params: { from: other_revision.id, to: last_revision.id }
|
before_state = group_state
|
||||||
expect(response).to have_http_status(:not_found)
|
post '/wiki', params: { title:, body: 'body' },
|
||||||
|
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(group_state).to eq(before_state)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns 404 when diff to belongs to another page' do
|
|
||||||
get "/wiki/#{ page.id }/diff",
|
|
||||||
params: { from: first_revision.id, to: other_revision.id }
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'retains legacy history metadata and ordering' do
|
|
||||||
get '/wiki/changes', params: { id: page.id }
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.map { |row| row.fetch('revision_id') }).to eq(
|
|
||||||
[last_revision.id, first_revision.id])
|
|
||||||
expect(json.first).to include(
|
|
||||||
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
|
|
||||||
'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3),
|
|
||||||
'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil },
|
|
||||||
'user' => { 'id' => user.id, 'name' => user.name })
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
require 'timeout'
|
||||||
|
|
||||||
|
RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
||||||
|
self.use_transactional_tests = false
|
||||||
|
|
||||||
|
before do
|
||||||
|
expect(Rails.env.test?).to be(true)
|
||||||
|
expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i)
|
||||||
|
expect(ApplicationRecord.connection_pool.size).to be >= 3
|
||||||
|
@prefix = "shared_ids_#{ SecureRandom.hex(8) }"
|
||||||
|
@tag = create(:tag, primary_name: "#{ @prefix }_owner")
|
||||||
|
@group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1)
|
||||||
|
@tag_ids = Queue.new
|
||||||
|
@threads = []
|
||||||
|
@release = Queue.new
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
@release << true if @release
|
||||||
|
begin
|
||||||
|
Array(@threads).each do |thread|
|
||||||
|
raise 'Shared ID worker did not finish' unless thread.join(15)
|
||||||
|
end
|
||||||
|
ensure
|
||||||
|
TagDescriptionVersion.where(tag_id: @tag.id).delete_all if @tag
|
||||||
|
if @group
|
||||||
|
TagGroupVersion.where(tag_group_id: @group.id).delete_all
|
||||||
|
@group.delete
|
||||||
|
end
|
||||||
|
if @prefix
|
||||||
|
ExternalTag.where(name: "#{ @prefix }_external").delete_all
|
||||||
|
TagGroup.where(name: "#{ @prefix }_group").delete_all
|
||||||
|
end
|
||||||
|
TagDescription.where(tag_id: @tag.id).delete_all if @tag
|
||||||
|
ids = []
|
||||||
|
ids << @tag_ids.pop until @tag_ids.empty? if @tag_ids
|
||||||
|
ids << @tag.id if @tag
|
||||||
|
TagName.where(tag_id: ids).delete_all
|
||||||
|
Tag.where(id: ids).delete_all
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def await_queue queue
|
||||||
|
Timeout.timeout(15) { queue.pop }
|
||||||
|
end
|
||||||
|
|
||||||
|
def worker &block
|
||||||
|
Thread.new do
|
||||||
|
Thread.current.report_on_exception = false
|
||||||
|
ApplicationRecord.connection_pool.with_connection(&block)
|
||||||
|
end.tap { @threads << _1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_resource kind
|
||||||
|
case kind
|
||||||
|
when :tag
|
||||||
|
Tag.create!(category: :general).tap { @tag_ids << _1.id }
|
||||||
|
when :external
|
||||||
|
ExternalTag.create!(platform: :nico, name: "#{ @prefix }_external")
|
||||||
|
when :description
|
||||||
|
TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1)
|
||||||
|
when :description_version
|
||||||
|
TagDescriptionVersion.create!(
|
||||||
|
tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1,
|
||||||
|
event_type: 'create', created_at: Time.current)
|
||||||
|
when :group_version
|
||||||
|
TagGroupVersion.create!(
|
||||||
|
tag_group: @group, name: @group.name, version_no: 1, event_type: 'create',
|
||||||
|
members: [], placements: [], created_at: Time.current)
|
||||||
|
when :group
|
||||||
|
TagGroup.create!(name: "#{ @prefix }_group", version_no: 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def hold_creation kind, rollback: false
|
||||||
|
ready = Queue.new
|
||||||
|
thread = worker do |connection|
|
||||||
|
id = nil
|
||||||
|
connection.transaction do
|
||||||
|
lock_name = nil
|
||||||
|
subscriber = lambda do |*args|
|
||||||
|
match = args.last.fetch(:sql).match(/GET_LOCK\('([^']+)'/)
|
||||||
|
lock_name = match[1] if match
|
||||||
|
end
|
||||||
|
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||||
|
id = create_resource(kind).id
|
||||||
|
end
|
||||||
|
ready << [id, connection.select_value('SELECT CONNECTION_ID()').to_i, lock_name]
|
||||||
|
await_queue(@release)
|
||||||
|
raise ActiveRecord::Rollback if rollback
|
||||||
|
end
|
||||||
|
id
|
||||||
|
end
|
||||||
|
[thread, await_queue(ready)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def result thread
|
||||||
|
raise 'Shared ID worker did not finish' unless thread.join(15)
|
||||||
|
thread.value
|
||||||
|
end
|
||||||
|
|
||||||
|
{ tags: [:external, :tag], wiki: [:description, :group],
|
||||||
|
wiki_history: [:description_version, :group_version] }.each do |space, kinds|
|
||||||
|
[false, true].each do |rollback|
|
||||||
|
it "serialises #{ space } inserts through #{ rollback ? 'rollback' : 'commit' }" do
|
||||||
|
tables = SharedResourceIdAllocator::SPACES.fetch(space.to_s)
|
||||||
|
maxima = tables.map do |table|
|
||||||
|
ApplicationRecord.connection.select_value("SELECT MAX(id) FROM #{ table }").to_i
|
||||||
|
end
|
||||||
|
first, (first_id, connection_id, lock_name) = hold_creation(kinds.first, rollback:)
|
||||||
|
expect(first_id).to be > maxima.max
|
||||||
|
connection = ApplicationRecord.connection
|
||||||
|
quoted_lock = connection.quote(lock_name)
|
||||||
|
expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i)
|
||||||
|
.to eq(connection_id)
|
||||||
|
|
||||||
|
started = Queue.new
|
||||||
|
second = worker do |other|
|
||||||
|
started << other.select_value('SELECT CONNECTION_ID()').to_i
|
||||||
|
create_resource(kinds.last).id
|
||||||
|
end
|
||||||
|
expect(await_queue(started)).not_to eq(connection_id)
|
||||||
|
@release << true
|
||||||
|
expect(result(first)).to eq(first_id)
|
||||||
|
expect(result(second)).to be > first_id
|
||||||
|
expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows the other ID space to commit while the first space remains locked' do
|
||||||
|
first, (_id, connection_id, lock_name) = hold_creation(:external)
|
||||||
|
second = worker { create_resource(:group).id }
|
||||||
|
expect(result(second)).to be_positive
|
||||||
|
connection = ApplicationRecord.connection
|
||||||
|
quoted_lock = connection.quote(lock_name)
|
||||||
|
expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i)
|
||||||
|
.to eq(connection_id)
|
||||||
|
@release << true
|
||||||
|
result(first)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises on a real lock acquisition failure without inserting a record' do
|
||||||
|
first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true)
|
||||||
|
stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0)
|
||||||
|
expect { create_resource(:external) }.to raise_error(ActiveRecord::LockWaitTimeout)
|
||||||
|
expect(ExternalTag.where(name: "#{ @prefix }_external")).not_to exist
|
||||||
|
@release << true
|
||||||
|
result(first)
|
||||||
|
connection = ApplicationRecord.connection
|
||||||
|
quoted_lock = connection.quote(lock_name)
|
||||||
|
expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i)
|
||||||
|
.to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe SharedResourceIdAllocator do
|
||||||
|
let(:tag) { create(:tag) }
|
||||||
|
|
||||||
|
def resource table, id: nil, number: 1
|
||||||
|
case table
|
||||||
|
when 'tags'
|
||||||
|
Tag.new(id:, category: :general)
|
||||||
|
when 'external_tags'
|
||||||
|
ExternalTag.new(id:, platform: :nico, name: "external #{ number }")
|
||||||
|
when 'tag_descriptions'
|
||||||
|
TagDescription.new(id:, tag:, language_code: 'ja', body: 'body', version_no: number)
|
||||||
|
when 'tag_groups'
|
||||||
|
TagGroup.new(id:, name: "group #{ number }", version_no: number)
|
||||||
|
when 'tag_description_versions'
|
||||||
|
TagDescriptionVersion.new(
|
||||||
|
id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number,
|
||||||
|
event_type: 'create', created_at: Time.current)
|
||||||
|
when 'tag_group_versions'
|
||||||
|
TagGroupVersion.new(
|
||||||
|
id:, tag_group_id: group.id, name: group.name, version_no: number,
|
||||||
|
event_type: 'create', members: [], placements: [], created_at: Time.current)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:group) { TagGroup.create!(name: 'history owner', version_no: 1) }
|
||||||
|
|
||||||
|
described_class::SPACES.each do |space, tables|
|
||||||
|
tables.permutation.each do |first_table, second_table|
|
||||||
|
it "keeps deleted #{ first_table } IDs reserved for #{ second_table }" do
|
||||||
|
first = resource(first_table)
|
||||||
|
first.save!
|
||||||
|
used_id = first.id
|
||||||
|
first.class.where(id: first.id).delete_all
|
||||||
|
second = resource(second_table)
|
||||||
|
second.save!
|
||||||
|
expect(second.id).to be > used_id
|
||||||
|
end
|
||||||
|
|
||||||
|
it "advances #{ space } after an explicit INSERT in #{ first_table }" do
|
||||||
|
first = resource(first_table)
|
||||||
|
first.save!
|
||||||
|
high = first.id + 100
|
||||||
|
explicit = resource(first_table, id: high, number: 2)
|
||||||
|
first.class.where(id: first.id).delete_all
|
||||||
|
explicit.save!
|
||||||
|
explicit.class.where(id: explicit.id).delete_all
|
||||||
|
second = resource(second_table)
|
||||||
|
second.save!
|
||||||
|
expect(second.id).to be > high
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allows explicit overlapping Id.s in #{ space }" do
|
||||||
|
first = resource(tables.first)
|
||||||
|
first.save!
|
||||||
|
|
||||||
|
second = resource(tables.last, id: first.id)
|
||||||
|
second.save!
|
||||||
|
|
||||||
|
expect(second.id).to eq(first.id)
|
||||||
|
expect(first.reload.id).to eq(first.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "preserves historical #{ space } overlaps without repairing them" do
|
||||||
|
first = resource(tables.first)
|
||||||
|
first.save!
|
||||||
|
second = resource(tables.last)
|
||||||
|
second.save!
|
||||||
|
second.class.where(id: second.id).update_all(id: first.id)
|
||||||
|
second = second.class.find(first.id)
|
||||||
|
snapshots = [first.reload.attributes, second.reload.attributes]
|
||||||
|
next_record = resource(tables.last, number: 2)
|
||||||
|
next_record.save!
|
||||||
|
expect(next_record.id).to be > first.id
|
||||||
|
expect(first.reload.attributes).to eq(snapshots.first)
|
||||||
|
expect(second.reload.attributes).to eq(snapshots.last)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reserves a Tag ID referenced only by remaining description history' do
|
||||||
|
high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000
|
||||||
|
TagDescriptionVersion.create!(
|
||||||
|
tag_id: high, language_code: 'ja', version_no: 1, body: 'history',
|
||||||
|
event_type: 'create', created_at: Time.current)
|
||||||
|
expect(create(:tag).id).to be > high
|
||||||
|
expect(create(:external_tag).id).to be > high
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reserves a group ID referenced only by remaining group history' do
|
||||||
|
high = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1000
|
||||||
|
version = resource('tag_group_versions')
|
||||||
|
version.save!
|
||||||
|
TagGroupVersion.where(id: version.id).update_all(tag_group_id: high)
|
||||||
|
expect(TagGroup.create!(name: 'replacement', version_no: 1).id).to be > high
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
|
||||||
|
let(:record) { build(:external_tag) }
|
||||||
|
let(:connection) { double('connection') }
|
||||||
|
let(:transaction) { double('transaction', open?: true) }
|
||||||
|
let(:callbacks) { { } }
|
||||||
|
let(:sql) { [] }
|
||||||
|
|
||||||
|
before do
|
||||||
|
config = double('config', database: 'test_shared_ids')
|
||||||
|
allow(connection).to receive_message_chain(:pool, :db_config).and_return(config)
|
||||||
|
allow(connection).to receive(:quote) { "'#{ _1 }'" }
|
||||||
|
allow(connection).to receive(:quote_column_name) { "`#{ _1 }`" }
|
||||||
|
allow(connection).to receive(:execute) { sql << _1 }
|
||||||
|
allow(connection).to receive(:select_all) do |statement|
|
||||||
|
tables = described_class::SPACES.values.flatten.select do |table|
|
||||||
|
statement.include?("'#{ table }'")
|
||||||
|
end
|
||||||
|
tables.map { { 'TABLE_NAME' => _1, 'ENGINE' => 'InnoDB', 'AUTO_INCREMENT' => 9 } }
|
||||||
|
end
|
||||||
|
allow(connection).to receive(:quote_table_name) { "`#{ _1 }`" }
|
||||||
|
allow(connection).to receive(:select_value) do |statement|
|
||||||
|
sql << statement
|
||||||
|
if statement.include?('GET_LOCK') || statement.include?('RELEASE_LOCK')
|
||||||
|
1
|
||||||
|
elsif statement.include?('`tags`')
|
||||||
|
8
|
||||||
|
else
|
||||||
|
3
|
||||||
|
end
|
||||||
|
end
|
||||||
|
allow(ExternalTag).to receive(:connection).and_return(connection)
|
||||||
|
allow(ExternalTag).to receive(:current_transaction).and_return(transaction)
|
||||||
|
allow(ExternalTag).to receive(:uncached).and_yield
|
||||||
|
[:after_commit, :after_rollback].each do |event|
|
||||||
|
allow(transaction).to receive(event) { |&callback| callbacks[event] = callback }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
[:after_commit, :after_rollback].each do |event|
|
||||||
|
it "holds the shared lock until #{ event }" do
|
||||||
|
described_class.assign!(record)
|
||||||
|
expect(record.id).to eq(9)
|
||||||
|
expect(sql.grep(/RELEASE_LOCK/)).to be_empty
|
||||||
|
callbacks.fetch(event).call
|
||||||
|
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not silently proceed after an acquisition timeout' do
|
||||||
|
allow(connection).to receive(:select_value).and_return(0)
|
||||||
|
expect { described_class.assign!(record) }.to raise_error(ActiveRecord::LockWaitTimeout)
|
||||||
|
expect(record.id).to be_nil
|
||||||
|
expect(callbacks).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'releases a successfully acquired lock when allocation fails and rolls back' do
|
||||||
|
allow(connection).to receive(:select_all).and_raise('query failed')
|
||||||
|
expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed')
|
||||||
|
callbacks.fetch(:after_rollback).call
|
||||||
|
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts an explicit ID above the reserved position' do
|
||||||
|
record.id = 50
|
||||||
|
described_class.assign!(record)
|
||||||
|
expect(record.id).to eq(50)
|
||||||
|
expect(sql.grep(/FOR UPDATE/)).to be_empty
|
||||||
|
callbacks.fetch(:after_commit).call
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the same named lock within each space and different locks between spaces' do
|
||||||
|
locks = [Tag, ExternalTag, TagDescription, TagGroup, TagDescriptionVersion, TagGroupVersion].map do |model|
|
||||||
|
allow(model).to receive(:connection).and_return(connection)
|
||||||
|
allow(model).to receive(:current_transaction).and_return(transaction)
|
||||||
|
allow(model).to receive(:uncached).and_yield
|
||||||
|
record = model.new(id: 50)
|
||||||
|
described_class.assign!(record)
|
||||||
|
statement = sql.grep(/GET_LOCK/).last
|
||||||
|
callbacks.fetch(:after_commit).call
|
||||||
|
statement
|
||||||
|
end
|
||||||
|
expect(locks[0]).to eq(locks[1])
|
||||||
|
expect(locks[2]).to eq(locks[3])
|
||||||
|
expect(locks[4]).to eq(locks[5])
|
||||||
|
expect([locks[0], locks[2], locks[4]].uniq.size).to eq(3)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'reports lock release failures' do
|
||||||
|
described_class.assign!(record)
|
||||||
|
allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil)
|
||||||
|
expect { callbacks.fetch(:after_commit).call }
|
||||||
|
.to raise_error(ActiveRecord::ActiveRecordError, /lock release failed/)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
require 'digest'
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Wiki::Commit do
|
|
||||||
let(:user) { create_member_user! }
|
|
||||||
|
|
||||||
def create_page title:, body: 'initial body'
|
|
||||||
described_class.create_content!(
|
|
||||||
tag_name: create(:tag_name, name: title),
|
|
||||||
body:,
|
|
||||||
created_by_user: user,
|
|
||||||
message: 'init')
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.create_content!' do
|
|
||||||
it 'creates page, revision, and version with normalised body' do
|
|
||||||
expect {
|
|
||||||
described_class.create_content!(
|
|
||||||
tag_name: create(:tag_name, name: 'commit_integrity_create'),
|
|
||||||
body: "a\r\nb\r\n\r\n",
|
|
||||||
created_by_user: user,
|
|
||||||
message: 'init')
|
|
||||||
}
|
|
||||||
.to change(WikiPage, :count).by(1)
|
|
||||||
.and change(WikiRevision, :count).by(1)
|
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' })
|
|
||||||
revision = page.current_revision
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(page.body).to eq("a\nb")
|
|
||||||
expect(revision.lines_count).to eq(2)
|
|
||||||
expect(version.body).to eq("a\nb")
|
|
||||||
expect(version.reason).to eq('init')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'rejects body that becomes blank after normalisation' do
|
|
||||||
tag_name = create(:tag_name, name: 'commit_integrity_blank')
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.create_content!(
|
|
||||||
tag_name:,
|
|
||||||
body: "\r\n\r\n",
|
|
||||||
created_by_user: user,
|
|
||||||
message: 'blank')
|
|
||||||
}
|
|
||||||
.to raise_error(ActiveRecord::RecordInvalid)
|
|
||||||
|
|
||||||
expect(WikiPage.where(tag_name:)).not_to exist
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.content!' do
|
|
||||||
it 'updates page body, revision, and version' do
|
|
||||||
page = create_page(title: 'commit_integrity_update', body: 'before')
|
|
||||||
current_id = page.current_revision.id
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: 'after',
|
|
||||||
created_user: user,
|
|
||||||
message: 'edit',
|
|
||||||
base_revision_id: current_id)
|
|
||||||
}
|
|
||||||
.to change(WikiRevision, :count).by(1)
|
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
page.reload
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(page.body).to eq('after')
|
|
||||||
expect(version.body).to eq('after')
|
|
||||||
expect(version.reason).to eq('edit')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not record tag_version on body-only wiki update' do
|
|
||||||
tag_name = create(:tag_name, name: 'commit_integrity_linked_tag')
|
|
||||||
tag = create(:tag, primary_tag_name: tag_name, category: :general)
|
|
||||||
|
|
||||||
page =
|
|
||||||
described_class.create_content!(
|
|
||||||
tag_name:,
|
|
||||||
body: 'before',
|
|
||||||
created_by_user: user,
|
|
||||||
message: 'init')
|
|
||||||
|
|
||||||
TagVersionRecorder.record!(
|
|
||||||
tag:,
|
|
||||||
event_type: :create,
|
|
||||||
created_by_user: user)
|
|
||||||
|
|
||||||
before_count = tag.reload.tag_versions.count
|
|
||||||
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: 'after',
|
|
||||||
created_user: user,
|
|
||||||
message: 'edit',
|
|
||||||
base_revision_id: page.current_revision.id)
|
|
||||||
|
|
||||||
expect(tag.reload.tag_versions.count).to eq(before_count)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'raises conflict and leaves page, revision, and version unchanged' do
|
|
||||||
page = create_page(title: 'commit_integrity_conflict', body: 'first')
|
|
||||||
stale_id = page.current_revision.id
|
|
||||||
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: 'second',
|
|
||||||
created_user: user,
|
|
||||||
message: 'second',
|
|
||||||
base_revision_id: stale_id)
|
|
||||||
|
|
||||||
page.reload
|
|
||||||
before_body = page.body
|
|
||||||
before_revision_count = page.wiki_revisions.count
|
|
||||||
before_version_count = page.wiki_versions.count
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: 'third',
|
|
||||||
created_user: user,
|
|
||||||
message: 'stale',
|
|
||||||
base_revision_id: stale_id)
|
|
||||||
}
|
|
||||||
.to raise_error(Wiki::Commit::Conflict)
|
|
||||||
|
|
||||||
page.reload
|
|
||||||
expect(page.body).to eq(before_body)
|
|
||||||
expect(page.wiki_revisions.count).to eq(before_revision_count)
|
|
||||||
expect(page.wiki_versions.count).to eq(before_version_count)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'deduplicates duplicated missing wiki lines' do
|
|
||||||
page = create_page(title: 'commit_integrity_dedup', body: 'before')
|
|
||||||
duplicated = 'commit_integrity_duplicate_line'
|
|
||||||
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: "#{ duplicated }\n#{ duplicated }",
|
|
||||||
created_user: user,
|
|
||||||
message: 'dedup',
|
|
||||||
base_revision_id: page.current_revision.id)
|
|
||||||
|
|
||||||
revision = page.reload.current_revision
|
|
||||||
|
|
||||||
expect(WikiLine.where(body: duplicated).count).to eq(1)
|
|
||||||
expect(revision.wiki_revision_lines.count).to eq(2)
|
|
||||||
expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.redirect!' do
|
|
||||||
it 'raises because redirect revisions are deprecated' do
|
|
||||||
page = create_page(title: 'commit_integrity_redirect_source', body: 'source')
|
|
||||||
target = create_page(title: 'commit_integrity_redirect_target', body: 'target')
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.redirect!(
|
|
||||||
page:,
|
|
||||||
redirect_page: target,
|
|
||||||
created_user: user,
|
|
||||||
message: 'redirect',
|
|
||||||
base_revision_id: page.current_revision.id)
|
|
||||||
}
|
|
||||||
.to raise_error(RuntimeError, '廃止しました.')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Wiki::Commit do
|
|
||||||
let(:user) { create_member_user! }
|
|
||||||
|
|
||||||
def create_page(title: 'commit_spec_page', body: 'initial body')
|
|
||||||
tag_name = create(:tag_name, name: title)
|
|
||||||
|
|
||||||
Wiki::Commit.create_content!(
|
|
||||||
tag_name:,
|
|
||||||
body:,
|
|
||||||
created_by_user: user,
|
|
||||||
message: 'init')
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.content!' do
|
|
||||||
it 'stores normalised body in wiki_pages and wiki_versions' do
|
|
||||||
page = create_page(title: 'commit_normalised_page')
|
|
||||||
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: "a\r\nb\r\n\r\n",
|
|
||||||
created_user: user,
|
|
||||||
message: 'init'
|
|
||||||
)
|
|
||||||
|
|
||||||
page.reload
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(page.body).to eq("a\nb")
|
|
||||||
expect(version.body).to eq("a\nb")
|
|
||||||
expect(page.current_revision.lines_count).to eq(2)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'deduplicates duplicated missing wiki lines before upsert' do
|
|
||||||
page = create_page(title: 'commit_duplicate_line_page')
|
|
||||||
duplicated = 'commit_duplicate_line'
|
|
||||||
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: "#{ duplicated }\n#{ duplicated }",
|
|
||||||
created_user: user,
|
|
||||||
message: 'init'
|
|
||||||
)
|
|
||||||
|
|
||||||
page.reload
|
|
||||||
|
|
||||||
expect(WikiLine.where(body: duplicated).count).to eq(1)
|
|
||||||
expect(page.current_revision.lines_count).to eq(2)
|
|
||||||
expect(page.current_revision.wiki_revision_lines.count).to eq(2)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'raises conflict when base_revision_id is stale' do
|
|
||||||
page = create_page(title: 'commit_conflict_page')
|
|
||||||
|
|
||||||
first = described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: 'first',
|
|
||||||
created_user: user,
|
|
||||||
message: 'first'
|
|
||||||
)
|
|
||||||
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: 'second',
|
|
||||||
created_user: user,
|
|
||||||
message: 'second',
|
|
||||||
base_revision_id: first.id
|
|
||||||
)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: 'third',
|
|
||||||
created_user: user,
|
|
||||||
message: 'third',
|
|
||||||
base_revision_id: first.id
|
|
||||||
)
|
|
||||||
}.to raise_error(Wiki::Commit::Conflict)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not record tag version when corresponding tag has no versions' do
|
|
||||||
tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions')
|
|
||||||
tag = create(:tag, primary_tag_name: tag_name, category: :general)
|
|
||||||
|
|
||||||
page =
|
|
||||||
described_class.create_content!(
|
|
||||||
tag_name:,
|
|
||||||
body: 'before',
|
|
||||||
created_by_user: user,
|
|
||||||
message: 'init')
|
|
||||||
|
|
||||||
expect(tag.reload.tag_versions.count).to eq(0)
|
|
||||||
|
|
||||||
current_revision_id = page.current_revision.id
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: 'after',
|
|
||||||
created_user: user,
|
|
||||||
message: 'edit',
|
|
||||||
base_revision_id: current_revision_id)
|
|
||||||
}.to change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(tag.reload.tag_versions.count).to eq(0)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not record tag version when corresponding tag has no versions' do
|
|
||||||
tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions')
|
|
||||||
tag = create(:tag, primary_tag_name: tag_name, category: :general)
|
|
||||||
|
|
||||||
page =
|
|
||||||
described_class.create_content!(
|
|
||||||
tag_name:,
|
|
||||||
body: 'before',
|
|
||||||
created_by_user: user,
|
|
||||||
message: 'init')
|
|
||||||
|
|
||||||
current_revision_id = page.current_revision.id
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.content!(
|
|
||||||
page:,
|
|
||||||
body: 'after',
|
|
||||||
created_user: user,
|
|
||||||
message: 'edit',
|
|
||||||
base_revision_id: current_revision_id)
|
|
||||||
}.to change(WikiVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(tag.reload.tag_versions.count).to eq(0)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.redirect!' do
|
|
||||||
it 'raises because redirect revisions are deprecated' do
|
|
||||||
page = create_page(title: 'commit_redirect_source')
|
|
||||||
target = create_page(title: 'commit_redirect_target')
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.redirect!(
|
|
||||||
page:,
|
|
||||||
redirect_page: target,
|
|
||||||
created_user: user,
|
|
||||||
message: 'redirect'
|
|
||||||
)
|
|
||||||
}.to raise_error(RuntimeError, '廃止しました.')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe WikiVersionRecorder do
|
|
||||||
let(:user) { create_member_user! }
|
|
||||||
|
|
||||||
def create_page title:, body: 'body'
|
|
||||||
Wiki::Commit.create_content!(
|
|
||||||
tag_name: create(:tag_name, name: title),
|
|
||||||
body:,
|
|
||||||
created_by_user: user,
|
|
||||||
message: 'init')
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '.record!' do
|
|
||||||
it 'records title, body, reason, user, and version number' do
|
|
||||||
page = create_page(title: 'wiki_version_recorder_basic', body: 'body')
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.record!(
|
|
||||||
page:,
|
|
||||||
event_type: :update,
|
|
||||||
reason: 'manual reason',
|
|
||||||
created_by_user: user)
|
|
||||||
}
|
|
||||||
.to change { page.reload.wiki_versions.count }.by(1)
|
|
||||||
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(version).to have_attributes(
|
|
||||||
version_no: 2,
|
|
||||||
event_type: 'update',
|
|
||||||
title: 'wiki_version_recorder_basic',
|
|
||||||
body: 'body',
|
|
||||||
reason: 'manual reason',
|
|
||||||
created_by_user_id: user.id
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not create duplicated update version for identical snapshot' do
|
|
||||||
page = create_page(title: 'wiki_version_recorder_duplicate', body: 'body')
|
|
||||||
|
|
||||||
described_class.record!(
|
|
||||||
page:,
|
|
||||||
event_type: :update,
|
|
||||||
reason: nil,
|
|
||||||
created_by_user: user)
|
|
||||||
|
|
||||||
before_count = page.reload.wiki_versions.count
|
|
||||||
|
|
||||||
described_class.record!(
|
|
||||||
page:,
|
|
||||||
event_type: :update,
|
|
||||||
reason: nil,
|
|
||||||
created_by_user: user)
|
|
||||||
|
|
||||||
expect(page.reload.wiki_versions.count).to eq(before_count)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates update version when title changes' do
|
|
||||||
page = create_page(title: 'wiki_version_recorder_title_before', body: 'body')
|
|
||||||
page.tag_name.update!(name: 'wiki_version_recorder_title_after')
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.record!(
|
|
||||||
page:,
|
|
||||||
event_type: :update,
|
|
||||||
reason: 'rename',
|
|
||||||
created_by_user: user)
|
|
||||||
}
|
|
||||||
.to change { page.reload.wiki_versions.count }.by(1)
|
|
||||||
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(version.title).to eq('wiki_version_recorder_title_after')
|
|
||||||
expect(version.body).to eq('body')
|
|
||||||
expect(version.reason).to eq('rename')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates update version when body changes' do
|
|
||||||
page = create_page(title: 'wiki_version_recorder_body', body: 'before')
|
|
||||||
page.update!(body: 'after')
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.record!(
|
|
||||||
page:,
|
|
||||||
event_type: :update,
|
|
||||||
reason: 'body',
|
|
||||||
created_by_user: user)
|
|
||||||
}
|
|
||||||
.to change { page.reload.wiki_versions.count }.by(1)
|
|
||||||
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(version.title).to eq('wiki_version_recorder_body')
|
|
||||||
expect(version.body).to eq('after')
|
|
||||||
expect(version.reason).to eq('body')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
RSpec.shared_context 'Ainu locale contract' do
|
||||||
|
let(:ainu_locale) { instance_double(Locale, language_code: 'ain', script_code: 'Jpan') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Only the foreign-key reference is seeded; no Locale or enum is enabled.
|
||||||
|
Language.connection.execute(<<~SQL)
|
||||||
|
INSERT IGNORE INTO languages (code, name, created_at)
|
||||||
|
VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP)
|
||||||
|
SQL
|
||||||
|
allow(Locale).to receive(:find_by).and_call_original
|
||||||
|
allow(Locale).to receive(:find_by).with(code: 'ain').and_return(ainu_locale)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_ainu_name tag, name
|
||||||
|
TagName.insert_all!([{
|
||||||
|
tag_id: tag.id, language_code: 'ain', script_code: 'Jpan', name:,
|
||||||
|
primary_flg: true, auto_generated: false,
|
||||||
|
created_at: Time.current, updated_at: Time.current }])
|
||||||
|
tag.tag_names.reset
|
||||||
|
TagName.find_by!(tag_id: tag.id, language_code: 'ain', name:)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,8 +7,7 @@ module TagGroupRecords
|
|||||||
'tag_groups', 'tag_group_tags', 'tag_group_groups',
|
'tag_groups', 'tag_group_tags', 'tag_group_groups',
|
||||||
'tag_group_placements', 'tag_group_versions'].freeze
|
'tag_group_placements', 'tag_group_versions'].freeze
|
||||||
WRITE_TABLES = GROUP_TABLES + [
|
WRITE_TABLES = GROUP_TABLES + [
|
||||||
'wiki_pages', 'wiki_revisions', 'wiki_versions', 'wiki_lines',
|
'tag_names', 'tag_description_versions',
|
||||||
'wiki_revision_lines', 'tag_names', 'tag_description_versions',
|
|
||||||
'tag_descriptions']
|
'tag_descriptions']
|
||||||
|
|
||||||
def group_connection = ApplicationRecord.connection
|
def group_connection = ApplicationRecord.connection
|
||||||
@@ -34,13 +33,11 @@ module TagGroupRecords
|
|||||||
WRITE_TABLES.to_h { |table| [table, group_rows(table)] }
|
WRITE_TABLES.to_h { |table| [table, group_rows(table)] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def expect_noop_group_update group_id:, page:
|
def expect_noop_group_update group_id:
|
||||||
before_state = group_state
|
before_state = group_state
|
||||||
group = TagGroup.find(group_id)
|
group = TagGroup.find(group_id)
|
||||||
version_id = TagGroupVersion.find_by!(
|
version_id = TagGroupVersion.find_by!(
|
||||||
tag_group_id: group_id, version_no: group.version_no).id
|
tag_group_id: group_id, version_no: group.version_no).id
|
||||||
revision_id = page.reload.current_revision.id
|
|
||||||
wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id
|
|
||||||
writes = []
|
writes = []
|
||||||
tables = Regexp.union(WRITE_TABLES)
|
tables = Regexp.union(WRITE_TABLES)
|
||||||
subscriber = lambda do |*args|
|
subscriber = lambda do |*args|
|
||||||
@@ -59,8 +56,6 @@ module TagGroupRecords
|
|||||||
end
|
end
|
||||||
expect(TagGroupVersion.find_by!(
|
expect(TagGroupVersion.find_by!(
|
||||||
tag_group_id: group_id, version_no: group.reload.version_no).id).to eq(version_id)
|
tag_group_id: group_id, version_no: group.reload.version_no).id).to eq(version_id)
|
||||||
expect(page.reload.current_revision.id).to eq(revision_id)
|
|
||||||
expect(page.wiki_versions.order(version_no: :desc).first.id).to eq(wiki_version_id)
|
|
||||||
expect(writes).to be_empty
|
expect(writes).to be_empty
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -99,25 +94,14 @@ module TagGroupRecords
|
|||||||
tag.reload
|
tag.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
def legacy_group name, body
|
def create_markdown_group name, body, locale: Locale.nipponese
|
||||||
# Historical fixtures preserve blank lines independently of today's writer.
|
parsed = TagGroupMarkdownParser.parse(locale, body:)
|
||||||
body = body.sub(/\n+\z/, '')
|
TagGroupOperation.create!(
|
||||||
page = WikiPage.create!(
|
locale, name:, actor: user, **parsed.slice(:description, :members, :placements))
|
||||||
tag_name: create(:tag_name, name: "タググループ:#{ name }"),
|
end
|
||||||
body:, created_user: user, updated_user: user)
|
|
||||||
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: user)
|
def group_markdown group, locale: Locale.nipponese
|
||||||
lines = body.split("\n")
|
TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group.reload))
|
||||||
shas = lines.map { Digest::SHA256.hexdigest(_1) }
|
|
||||||
revision = WikiRevision.create!(
|
|
||||||
wiki_page: page, kind: :content, created_user: user, lines_count: lines.size,
|
|
||||||
tree_sha256: Digest::SHA256.hexdigest(shas.join(',')))
|
|
||||||
lines.each_with_index do |line, position|
|
|
||||||
sha = shas.fetch(position)
|
|
||||||
line_id = WikiLine.find_by(sha256: sha)&.id || insert_group_row('wiki_lines', {
|
|
||||||
body: line, sha256: sha, created_at: Time.current, updated_at: Time.current })
|
|
||||||
WikiRevisionLine.create!(wiki_revision: revision, wiki_line_id: line_id, position:)
|
|
||||||
end
|
|
||||||
page
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def seed_group name:, placements: [], members: [], description: nil, id: nil
|
def seed_group name:, placements: [], members: [], description: nil, id: nil
|
||||||
@@ -176,16 +160,6 @@ module TagGroupRecords
|
|||||||
"/wiki/title/#{ CGI.escapeURIComponent("タググループ:#{ name }") }"
|
"/wiki/title/#{ CGI.escapeURIComponent("タググループ:#{ name }") }"
|
||||||
end
|
end
|
||||||
|
|
||||||
def migrate_groups
|
|
||||||
require_relative '../../db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages'
|
|
||||||
migration = MigrateTagGroupsFromWikiPages.new
|
|
||||||
@group_migration_messages = []
|
|
||||||
allow(migration).to receive(:say) do |message, *_args|
|
|
||||||
@group_migration_messages << message if message.start_with?('Wiki #', 'Tag Group')
|
|
||||||
end
|
|
||||||
migration.suppress_messages { migration.up }
|
|
||||||
end
|
|
||||||
|
|
||||||
def expect_group_snapshot group_id, number:, placements:, members:, description: nil
|
def expect_group_snapshot group_id, number:, placements:, members:, description: nil
|
||||||
current = group_rows('tag_groups', id: group_id).sole
|
current = group_rows('tag_groups', id: group_id).sole
|
||||||
version = group_version(group_id, number)
|
version = group_version(group_id, number)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
module WikiRecords
|
||||||
|
def seed_description(tag_name:, body:, created_by_user:, message: nil, locale: Locale.nipponese)
|
||||||
|
TagDescriptionOperation.create!(locale, tag: tag_name.tag, body:, created_by_user:, message:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def description_versions(description)
|
||||||
|
TagDescriptionVersion.where(
|
||||||
|
tag_id: description.tag_id, language_code: description.language_code)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_description_version(description)
|
||||||
|
description_versions(description).find_by!(version_no: description.reload.version_no)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
/help
|
||||||
|
|||||||
生成ファイル
+4
-43
@@ -38,6 +38,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"unist-util-visit-parents": "^6.0.1",
|
"unist-util-visit-parents": "^6.0.1",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2060,9 +2061,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2077,9 +2075,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2094,9 +2089,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2111,9 +2103,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2128,9 +2117,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2145,9 +2131,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2162,9 +2145,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2179,9 +2159,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2196,9 +2173,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2213,9 +2187,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2230,9 +2201,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2247,9 +2215,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2264,9 +2229,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -9114,10 +9076,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.3",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.0",
|
||||||
"unist-util-visit-parents": "^6.0.1",
|
"unist-util-visit-parents": "^6.0.1",
|
||||||
|
"yaml": "^2.9.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { generateHelpAssets } from './help-assets'
|
||||||
|
|
||||||
|
import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help'
|
||||||
|
|
||||||
|
vi.hoisted (() => {
|
||||||
|
vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor)
|
||||||
|
})
|
||||||
|
afterAll (() => vi.unstubAllGlobals ())
|
||||||
|
|
||||||
|
|
||||||
|
const readAsset = <T,> (modules: Map<string, string>, name: string): T => {
|
||||||
|
const source = modules.get (`virtual:help/${ name }`)!
|
||||||
|
const argument = source.slice ('export default JSON.parse('.length, -1)
|
||||||
|
return JSON.parse (JSON.parse (argument))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
describe ('Build-time Help assets', () => {
|
||||||
|
let root: string
|
||||||
|
|
||||||
|
beforeEach (async () => {
|
||||||
|
root = await mkdtemp (path.join (tmpdir (), 'btrc-help-assets-'))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach (async () => {
|
||||||
|
await rm (root, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const writeArticle = async (
|
||||||
|
identifier: string,
|
||||||
|
editions: Record<string, string>,
|
||||||
|
current = '2026-10-02.10',
|
||||||
|
) => {
|
||||||
|
const articleRoot = path.join (root, identifier)
|
||||||
|
const editionRoot = path.join (articleRoot, 'editions', 'ja')
|
||||||
|
await mkdir (editionRoot, { recursive: true })
|
||||||
|
await writeFile (path.join (articleRoot, 'article.yml'), [
|
||||||
|
`identifier: ${ identifier }`, 'canonical_language: ja', 'title: stale YAML title',
|
||||||
|
'languages:', ' ja:', ` current_edition: ${ current }`].join ('\n'))
|
||||||
|
await Promise.all (Object.entries (editions).map (([edition, source]) =>
|
||||||
|
writeFile (path.join (editionRoot, `${ edition }.mdx`), source)))
|
||||||
|
}
|
||||||
|
|
||||||
|
it ('generates current search and ordered published history from each edition H1', async () => {
|
||||||
|
await writeArticle ('guide', {
|
||||||
|
'2026-10-01': '# 旧ホーム\n\n過去本文',
|
||||||
|
'2026-10-02': '# ホーム\n\n最初の同日版',
|
||||||
|
'2026-10-02.2': '# ホーム\n\n第二版',
|
||||||
|
'2026-10-02.3': '# ホーム\n\n第三版',
|
||||||
|
'2026-10-02.10': '\uFEFF# 最新ホーム\r\n\r\n現在本文\r\n# 別見出し',
|
||||||
|
'2026-10-02.11': '# 未公開\n\n検索に含めない' })
|
||||||
|
const { modules } = await generateHelpAssets (root)
|
||||||
|
const articles = readAsset<Record<string, ArticleMeta>> (modules, 'articles')
|
||||||
|
expect (articles.guide.languages.ja).toEqual ({
|
||||||
|
currentEdition: '2026-10-02.10', publishedEditions: [
|
||||||
|
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] })
|
||||||
|
expect (readAsset<Record<string, HelpSearchEntry[]>> (modules, 'search').ja).toEqual ([{
|
||||||
|
identifier: 'guide', edition: '2026-10-02.10', title: '最新ホーム',
|
||||||
|
body: '\r\n現在本文\r\n# 別見出し' }])
|
||||||
|
const changes = readAsset<Record<string, HelpChange[]>> (modules, 'changes').ja
|
||||||
|
expect (changes.map (row => [row.edition, row.title, row.pred, row.change, row.date]))
|
||||||
|
.toEqual ([
|
||||||
|
['2026-10-02.10', '最新ホーム', '2026-10-02.3', 'update', '2026-10-02'],
|
||||||
|
['2026-10-02.3', 'ホーム', '2026-10-02.2', 'update', '2026-10-02'],
|
||||||
|
['2026-10-02.2', 'ホーム', '2026-10-02', 'update', '2026-10-02'],
|
||||||
|
['2026-10-02', 'ホーム', '2026-10-01', 'update', '2026-10-02'],
|
||||||
|
['2026-10-01', '旧ホーム', null, 'new', '2026-10-01']])
|
||||||
|
const titles = readAsset<Record<string, HelpTitleIndex>> (modules, 'titles').ja
|
||||||
|
expect (titles).toEqual ({ '旧ホーム': ['guide'], 'ホーム': ['guide'],
|
||||||
|
'最新ホーム': ['guide'] })
|
||||||
|
expect (modules.get ('virtual:help/loaders')).not.toContain ('2026-10-02.11')
|
||||||
|
expect (modules.get ('virtual:help/loaders')).toContain ('2026-10-02.10.mdx?raw')
|
||||||
|
expect (modules.get ('virtual:help/search')).not.toContain ('import(')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('retains ambiguous title identifiers and normalises width and case', async () => {
|
||||||
|
await writeArticle ('alpha', { '2026-10-02': '# HOME\n本文' }, '2026-10-02')
|
||||||
|
await writeArticle ('beta', { '2026-10-02': '# Home\n本文' }, '2026-10-02')
|
||||||
|
const { modules } = await generateHelpAssets (root)
|
||||||
|
expect (readAsset<Record<string, HelpTitleIndex>> (modules, 'titles').ja.home)
|
||||||
|
.toEqual (['alpha', 'beta'])
|
||||||
|
expect (readAsset<Record<string, HelpChange[]>> (modules, 'changes').ja
|
||||||
|
.map (row => row.identifier)).toEqual (['alpha', 'beta'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('rejects missing current files during generation', async () => {
|
||||||
|
await writeArticle ('guide', { '2026-10-02': '# ホーム' })
|
||||||
|
await expect (generateHelpAssets (root)).rejects.toThrow ('Missing current Help edition')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each (['本文\n# 遅い見出し', '# \n本文']) (
|
||||||
|
'rejects missing leading H1 at build time: %s', async source => {
|
||||||
|
await writeArticle ('guide', { '2026-10-02.10': source })
|
||||||
|
await expect (generateHelpAssets (root)).rejects.toThrow ('Missing leading Help H1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('rejects absent canonical metadata instead of generating incomplete assets', async () => {
|
||||||
|
await writeArticle ('guide', { '2026-10-02.10': '# ホーム' })
|
||||||
|
await writeFile (path.join (root, 'guide', 'article.yml'),
|
||||||
|
'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02.10')
|
||||||
|
await expect (generateHelpAssets (root)).rejects.toThrow ('Invalid Help metadata')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('rejects an empty Help checkout', async () => {
|
||||||
|
await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { readFile, readdir } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { normalizePath as normalisePath } from 'vite'
|
||||||
|
import { parse as parseYAML } from 'yaml'
|
||||||
|
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
type Language = { currentEdition: string; publishedEditions: string[] }
|
||||||
|
type Article = {
|
||||||
|
identifier: string
|
||||||
|
canonicalLanguage: string
|
||||||
|
languages: Record<string, Language> }
|
||||||
|
type Text = { title: string; body: string }
|
||||||
|
type SearchEntry = Text & { identifier: string; edition: string }
|
||||||
|
type Change = {
|
||||||
|
identifier: string
|
||||||
|
edition: string
|
||||||
|
title: string
|
||||||
|
pred: string | null
|
||||||
|
change: 'new' | 'update'
|
||||||
|
date: string }
|
||||||
|
type RawArticle = {
|
||||||
|
identifier: string
|
||||||
|
canonical_language: string
|
||||||
|
languages: Record<string, { current_edition: string }> }
|
||||||
|
|
||||||
|
const prefix = 'virtual:help/'
|
||||||
|
|
||||||
|
|
||||||
|
const compareEditions = (a: string, b: string): number => {
|
||||||
|
const [dateA, revisionA = '1'] = a.split ('.')
|
||||||
|
const [dateB, revisionB = '1'] = b.split ('.')
|
||||||
|
return dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const validateEdition = (edition: string): void => {
|
||||||
|
const [date, revision = '1'] = edition.split ('.')
|
||||||
|
if (!(/^\d{4}-\d{2}-\d{2}(?:\.[1-9]\d*)?$/.test (edition))
|
||||||
|
|| new Date (date).toISOString ().slice (0, 10) !== date
|
||||||
|
|| !(Number.isSafeInteger (Number (revision))))
|
||||||
|
throw new Error (`Invalid Help edition: ${ edition }`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const generateHelpAssets = async (
|
||||||
|
root: string,
|
||||||
|
): Promise<{ modules: Map<string, string>; files: string[] }> => {
|
||||||
|
const modules = new Map<string, string> ()
|
||||||
|
const files: string[] = [root]
|
||||||
|
const articles: Record<string, Article> = Object.create (null)
|
||||||
|
const search: Record<string, SearchEntry[]> = Object.create (null)
|
||||||
|
const changes: Record<string, Change[]> = Object.create (null)
|
||||||
|
const titles: Record<string, Record<string, string[]>> = Object.create (null)
|
||||||
|
const components: string[] = []
|
||||||
|
const sources: string[] = []
|
||||||
|
const texts: string[] = []
|
||||||
|
const directories = (await readdir (root, { withFileTypes: true }))
|
||||||
|
.filter (entry => entry.isDirectory () && !(entry.name.startsWith ('.')))
|
||||||
|
.sort ((a, b) => a.name.localeCompare (b.name))
|
||||||
|
|
||||||
|
for (const directory of directories)
|
||||||
|
{
|
||||||
|
const articlePath = path.join (root, directory.name, 'article.yml')
|
||||||
|
files.push (articlePath)
|
||||||
|
const raw = parseYAML (await readFile (articlePath, 'utf8')) as RawArticle | null
|
||||||
|
if (raw == null || raw.identifier !== directory.name
|
||||||
|
|| typeof raw.canonical_language !== 'string'
|
||||||
|
|| raw.languages == null || typeof raw.languages !== 'object'
|
||||||
|
|| !(Object.hasOwn (raw.languages, raw.canonical_language)))
|
||||||
|
throw new Error (`Invalid Help metadata: ${ articlePath }`)
|
||||||
|
|
||||||
|
const article: Article = {
|
||||||
|
identifier: raw.identifier,
|
||||||
|
canonicalLanguage: raw.canonical_language,
|
||||||
|
languages: Object.create (null) }
|
||||||
|
articles[raw.identifier] = article
|
||||||
|
|
||||||
|
for (const [language, metadata] of Object.entries (raw.languages))
|
||||||
|
{
|
||||||
|
if (!(/^[a-zA-Z0-9-]+$/.test (language))
|
||||||
|
|| typeof metadata?.current_edition !== 'string')
|
||||||
|
throw new Error (`Invalid Help language: ${ articlePath }/${ language }`)
|
||||||
|
|
||||||
|
const currentEdition = metadata.current_edition
|
||||||
|
validateEdition (currentEdition)
|
||||||
|
const editionRoot = path.join (root, directory.name, 'editions', language)
|
||||||
|
files.push (editionRoot)
|
||||||
|
const editions = (await readdir (editionRoot)).filter (name => name.endsWith ('.mdx'))
|
||||||
|
.map (name => name.slice (0, -4))
|
||||||
|
editions.forEach (validateEdition)
|
||||||
|
if (!(editions.includes (currentEdition)))
|
||||||
|
throw new Error (`Missing current Help edition: ${ editionRoot }/${ currentEdition }`)
|
||||||
|
|
||||||
|
const publishedEditions = editions
|
||||||
|
.filter (edition => compareEditions (edition, currentEdition) <= 0)
|
||||||
|
.sort (compareEditions)
|
||||||
|
article.languages[language] = { currentEdition, publishedEditions }
|
||||||
|
search[language] ??= []
|
||||||
|
changes[language] ??= []
|
||||||
|
titles[language] ??= Object.create (null)
|
||||||
|
|
||||||
|
for (const [index, edition] of publishedEditions.entries ())
|
||||||
|
{
|
||||||
|
const sourcePath = path.join (editionRoot, `${ edition }.mdx`)
|
||||||
|
files.push (sourcePath)
|
||||||
|
const mdx = (await readFile (sourcePath, 'utf8')).replace (/^\uFEFF/, '')
|
||||||
|
const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/)
|
||||||
|
if (heading == null || !(heading[1].trim ()))
|
||||||
|
throw new Error (`Missing leading Help H1: ${ sourcePath }`)
|
||||||
|
|
||||||
|
const text = { title: heading[1].trim (), body: mdx.slice (heading[0].length) }
|
||||||
|
const key = JSON.stringify (`${ raw.identifier }/${ language }/${ edition }`)
|
||||||
|
const textId = `${ prefix }text/${ raw.identifier }/${ language }/${ edition }`
|
||||||
|
const componentId = JSON.stringify (normalisePath (sourcePath))
|
||||||
|
const sourceId = JSON.stringify (normalisePath (sourcePath) + '?raw')
|
||||||
|
modules.set (textId, `export default ${ JSON.stringify (text) }`)
|
||||||
|
texts.push (`${ key }: () => import(${ JSON.stringify (textId) })`)
|
||||||
|
components.push (`${ key }: () => import(${ componentId })`)
|
||||||
|
sources.push (`${ key }: () => import(${ sourceId })`)
|
||||||
|
|
||||||
|
if (edition === currentEdition)
|
||||||
|
search[language].push ({ identifier: raw.identifier, edition, ...text })
|
||||||
|
changes[language].push ({ identifier: raw.identifier, edition, title: text.title,
|
||||||
|
pred: publishedEditions[index - 1] ?? null,
|
||||||
|
change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] })
|
||||||
|
const title = text.title.trim ().normalize ('NFKC').toLowerCase ()
|
||||||
|
const identifiers = titles[language][title] ??= []
|
||||||
|
if (!(identifiers.includes (raw.identifier)))
|
||||||
|
identifiers.push (raw.identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys (articles).length === 0)
|
||||||
|
throw new Error (`No Help articles found: ${ root }`)
|
||||||
|
|
||||||
|
Object.values (changes).forEach (rows => rows.sort ((a, b) =>
|
||||||
|
compareEditions (b.edition, a.edition) || a.identifier.localeCompare (b.identifier)))
|
||||||
|
for (const [name, data] of Object.entries ({ articles, search, changes, titles }))
|
||||||
|
{
|
||||||
|
modules.set (`${ prefix }${ name }`,
|
||||||
|
`export default JSON.parse(${ JSON.stringify (JSON.stringify (data)) })`)
|
||||||
|
}
|
||||||
|
modules.set (`${ prefix }loaders`, [
|
||||||
|
`export const components = { ${ components.join (',\n') } };`,
|
||||||
|
`export const sources = { ${ sources.join (',\n') } };`,
|
||||||
|
`export const texts = { ${ texts.join (',\n') } };`].join ('\n'))
|
||||||
|
return { modules, files }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const helpAssets = (): Plugin => {
|
||||||
|
const state: { root: string; data?: ReturnType<typeof generateHelpAssets> } = { root: '' }
|
||||||
|
const data = () => state.data ??= generateHelpAssets (state.root)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'help-assets',
|
||||||
|
configResolved (config) {
|
||||||
|
state.root = path.resolve (config.root, 'help')
|
||||||
|
},
|
||||||
|
async buildStart () {
|
||||||
|
state.data = generateHelpAssets (state.root)
|
||||||
|
const generated = await data ()
|
||||||
|
generated.files.forEach (file => this.addWatchFile (file))
|
||||||
|
},
|
||||||
|
resolveId (id) {
|
||||||
|
return id.startsWith (prefix) ? `\0${ id }` : null
|
||||||
|
},
|
||||||
|
async load (id) {
|
||||||
|
if (!(id.startsWith (`\0${ prefix }`)))
|
||||||
|
return null
|
||||||
|
const source = (await data ()).modules.get (id.slice (1))
|
||||||
|
if (source == null)
|
||||||
|
throw new Error (`Unknown Help asset: ${ id.slice (1) }`)
|
||||||
|
return source
|
||||||
|
},
|
||||||
|
configureServer (server) {
|
||||||
|
server.watcher.add (state.root)
|
||||||
|
server.watcher.on ('all', (_event, file) => {
|
||||||
|
if (!(file.startsWith (state.root + path.sep)) || !(/\.(mdx|yml)$/.test (file)))
|
||||||
|
return
|
||||||
|
state.data = undefined
|
||||||
|
for (const module of server.moduleGraph.idToModuleMap.values ())
|
||||||
|
{
|
||||||
|
if (module.id?.startsWith (`\0${ prefix }`))
|
||||||
|
server.moduleGraph.invalidateModule (module)
|
||||||
|
}
|
||||||
|
server.ws.send ({ type: 'full-reload' })
|
||||||
|
})
|
||||||
|
} }
|
||||||
|
}
|
||||||
+56
-80
@@ -15,7 +15,6 @@ import { Toaster } from '@/components/ui/toaster'
|
|||||||
import { apiPost, isApiError } from '@/lib/api'
|
import { apiPost, isApiError } from '@/lib/api'
|
||||||
import { applyClientAnimationMode,
|
import { applyClientAnimationMode,
|
||||||
applyClientAppearance,
|
applyClientAppearance,
|
||||||
type ClientAnimationMode,
|
|
||||||
fetchUserThemeSlots,
|
fetchUserThemeSlots,
|
||||||
fetchUserSettings,
|
fetchUserSettings,
|
||||||
getClientThemeMode,
|
getClientThemeMode,
|
||||||
@@ -25,7 +24,6 @@ import { applyClientAnimationMode,
|
|||||||
seedClientThemeMode } from '@/lib/settings'
|
seedClientThemeMode } from '@/lib/settings'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
|
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
|
||||||
|
|
||||||
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||||
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
||||||
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
||||||
@@ -34,12 +32,15 @@ import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
|
|||||||
import MaterialListPage from '@/pages/materials/MaterialListPage'
|
import MaterialListPage from '@/pages/materials/MaterialListPage'
|
||||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||||
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
|
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
|
||||||
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
|
||||||
import MorePage from '@/pages/MorePage'
|
import MorePage from '@/pages/MorePage'
|
||||||
import GekanatorPage from '@/pages/GekanatorPage'
|
import GekanatorPage from '@/pages/GekanatorPage'
|
||||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
import TOSPage from '@/pages/TOSPage.mdx'
|
import TOSPage from '@/pages/TOSPage.mdx'
|
||||||
|
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||||
|
import HelpChangesPage from '@/pages/help/HelpChangesPage'
|
||||||
|
import HelpDiffPage from '@/pages/help/HelpDiffPage'
|
||||||
|
import HelpListPage from '@/pages/help/HelpListPage'
|
||||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||||
@@ -60,90 +61,62 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
|||||||
|
|
||||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||||
|
|
||||||
|
import type { ClientAnimationMode } from '@/lib/settings'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||||
animationMode: ClientAnimationMode
|
animationMode: ClientAnimationMode
|
||||||
user: User | null
|
user: User | null
|
||||||
setUser: Dispatch<SetStateAction<User | null>> }) => {
|
setUser: Dispatch<SetStateAction<User | null>> }) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
|
||||||
|
const routes = (
|
||||||
|
<Routes location={location}>
|
||||||
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
|
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||||
|
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||||
|
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||||
|
<Route path="/tags" element={<TagListPage/>}/>
|
||||||
|
<Route path="/tags/:id" element={<TagDetailPage/>}/>
|
||||||
|
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
||||||
|
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||||
|
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
|
||||||
|
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
|
||||||
|
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
||||||
|
<Route path="/materials" element={<MaterialBasePage/>}>
|
||||||
|
<Route index element={<MaterialListPage/>}/>
|
||||||
|
<Route path="changes" element={<MaterialHistoryPage/>}/>
|
||||||
|
<Route path="new" element={<MaterialNewPage/>}/>
|
||||||
|
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
|
||||||
|
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
||||||
|
</Route>
|
||||||
|
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||||
|
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
|
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||||
|
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
||||||
|
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||||
|
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
||||||
|
<Route path="/help" element={<HelpListPage/>}/>
|
||||||
|
<Route path="/help/changes" element={<HelpChangesPage/>}/>
|
||||||
|
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
|
||||||
|
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||||
|
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||||
|
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||||
|
<Route path="/tos" element={<TOSPage/>}/>
|
||||||
|
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
|
||||||
|
<Route path="/more" element={<MorePage/>}/>
|
||||||
|
<Route path="*" element={<NotFound/>}/>
|
||||||
|
</Routes>)
|
||||||
|
|
||||||
if (animationMode === 'off')
|
if (animationMode === 'off')
|
||||||
{
|
return routes
|
||||||
return (
|
|
||||||
<Routes location={location}>
|
|
||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
|
||||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
|
||||||
<Route path="/tags" element={<TagListPage/>}/>
|
|
||||||
<Route path="/tags/:id" element={<TagDetailPage/>}/>
|
|
||||||
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
|
||||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
|
||||||
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
|
|
||||||
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
|
|
||||||
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
|
||||||
<Route path="/materials" element={<MaterialBasePage/>}>
|
|
||||||
<Route index element={<MaterialListPage/>}/>
|
|
||||||
<Route path="changes" element={<MaterialHistoryPage/>}/>
|
|
||||||
<Route path="new" element={<MaterialNewPage/>}/>
|
|
||||||
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
|
|
||||||
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
|
||||||
</Route>
|
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
|
||||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
|
||||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
|
||||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
|
||||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
|
||||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
|
||||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
|
||||||
<Route path="/tos" element={<TOSPage/>}/>
|
|
||||||
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
|
|
||||||
<Route path="/more" element={<MorePage/>}/>
|
|
||||||
<Route path="*" element={<NotFound/>}/>
|
|
||||||
</Routes>)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<Routes location={location}>
|
{routes}
|
||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
|
||||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
|
||||||
<Route path="/tags" element={<TagListPage/>}/>
|
|
||||||
<Route path="/tags/:id" element={<TagDetailPage/>}/>
|
|
||||||
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
|
||||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
|
||||||
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
|
|
||||||
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
|
|
||||||
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
|
||||||
<Route path="/materials" element={<MaterialBasePage/>}>
|
|
||||||
<Route index element={<MaterialListPage/>}/>
|
|
||||||
<Route path="changes" element={<MaterialHistoryPage/>}/>
|
|
||||||
<Route path="new" element={<MaterialNewPage/>}/>
|
|
||||||
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
|
|
||||||
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
|
||||||
</Route>
|
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
|
||||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
|
||||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
|
||||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
|
||||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
|
||||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
|
||||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
|
||||||
<Route path="/tos" element={<TOSPage/>}/>
|
|
||||||
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
|
|
||||||
<Route path="/more" element={<MorePage/>}/>
|
|
||||||
<Route path="*" element={<NotFound/>}/>
|
|
||||||
</Routes>
|
|
||||||
</AnimatePresence>)
|
</AnimatePresence>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,9 +139,9 @@ const RoutedApp: FC = () => {
|
|||||||
() => (
|
() => (
|
||||||
animationMode === 'off'
|
animationMode === 'off'
|
||||||
? { duration: 0 }
|
? { duration: 0 }
|
||||||
: animationMode === 'reduced'
|
: (animationMode === 'reduced'
|
||||||
? { duration: .08, ease: 'linear' as const }
|
? { duration: .08, ease: 'linear' as const }
|
||||||
: { duration: .2, ease: 'easeOut' as const }
|
: { duration: .2, ease: 'easeOut' as const })
|
||||||
),
|
),
|
||||||
[animationMode],
|
[animationMode],
|
||||||
)
|
)
|
||||||
@@ -285,10 +258,12 @@ const RoutedApp: FC = () => {
|
|||||||
</DialogueProvider>)
|
</DialogueProvider>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const router = createBrowserRouter ([{
|
const router = createBrowserRouter ([{
|
||||||
path: '*',
|
path: '*',
|
||||||
element: <RoutedApp/> }])
|
element: <RoutedApp/> }])
|
||||||
|
|
||||||
|
|
||||||
const App: FC = () => (
|
const App: FC = () => (
|
||||||
<>
|
<>
|
||||||
<RouteBlockerOverlay/>
|
<RouteBlockerOverlay/>
|
||||||
@@ -296,4 +271,5 @@ const App: FC = () => (
|
|||||||
<RouterProvider router={router}/>
|
<RouterProvider router={router}/>
|
||||||
</>)
|
</>)
|
||||||
|
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { FC, MouseEvent } from 'react'
|
|||||||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null }
|
||||||
|
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
||||||
|
|
||||||
|
|
||||||
export const menuOutline = (
|
export const menuOutline = (
|
||||||
@@ -40,6 +41,9 @@ export const menuOutline = (
|
|||||||
|
|
||||||
const materialFlg = /^\/materials\/\d+/.test (pathName)
|
const materialFlg = /^\/materials\/\d+/.test (pathName)
|
||||||
|
|
||||||
|
const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName)
|
||||||
|
const helpIdentifier = pathName.split ('/')[2] ?? ''
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ name: '広場', to: '/posts', subMenu: [
|
{ name: '広場', to: '/posts', subMenu: [
|
||||||
{ name: '一覧', to: '/posts' },
|
{ name: '一覧', to: '/posts' },
|
||||||
@@ -71,7 +75,7 @@ export const menuOutline = (
|
|||||||
visible: materialFlg && Boolean (material?.tag) },
|
visible: materialFlg && Boolean (material?.tag) },
|
||||||
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
|
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
|
||||||
visible: materialFlg }] },
|
visible: materialFlg }] },
|
||||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
{ name: 'Wiki', to: '/wiki', subMenu: [
|
||||||
{ name: '検索', to: '/wiki' },
|
{ name: '検索', to: '/wiki' },
|
||||||
{ name: '新規', to: '/wiki/new', visible: editable },
|
{ name: '新規', to: '/wiki/new', visible: editable },
|
||||||
{ name: '全体履歴', to: '/wiki/changes' },
|
{ name: '全体履歴', to: '/wiki/changes' },
|
||||||
@@ -82,6 +86,13 @@ export const menuOutline = (
|
|||||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||||
visible: wikiPageFlg && editable }] },
|
visible: wikiPageFlg && editable }] },
|
||||||
|
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
|
||||||
|
{ name: '検索', to: '/help' },
|
||||||
|
{ name: '全体履歴', to: '/help/changes' },
|
||||||
|
{ component: <Separator/>, visible: helpVsbl },
|
||||||
|
{ name: '履歴',
|
||||||
|
to: `/help/changes?identifier=${ helpIdentifier }`,
|
||||||
|
visible: helpVsbl }] },
|
||||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
{ name: 'おたのしみ', visible: false, subMenu: [
|
||||||
{ name: '上映会 (β)', to: '/theatres/1' },
|
{ name: '上映会 (β)', to: '/theatres/1' },
|
||||||
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
||||||
@@ -104,6 +115,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
const dirRef = useRef<(-1) | 1> (1)
|
const dirRef = useRef<(-1) | 1> (1)
|
||||||
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
||||||
const navRef = useRef<HTMLDivElement | null> (null)
|
const navRef = useRef<HTMLDivElement | null> (null)
|
||||||
|
const layoutRef = useRef<HTMLDivElement | null> (null)
|
||||||
|
const barRef = useRef<HTMLElement | null> (null)
|
||||||
|
|
||||||
const measure = (idx: number) => {
|
const measure = (idx: number) => {
|
||||||
const nav = navRef.current
|
const nav = navRef.current
|
||||||
@@ -128,6 +141,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
width: 0,
|
width: 0,
|
||||||
visible: false })
|
visible: false })
|
||||||
const [menuOpen, setMenuOpen] = useState (false)
|
const [menuOpen, setMenuOpen] = useState (false)
|
||||||
|
const [desktop, setDesktop] = useState (false)
|
||||||
|
const [density, setDensity] = useState<NavDensity> ('auto')
|
||||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||||||
@@ -194,15 +209,81 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
const dir = dirRef.current
|
const dir = dirRef.current
|
||||||
|
|
||||||
useLayoutEffect (() => {
|
useLayoutEffect (() => {
|
||||||
const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
const layout = layoutRef.current
|
||||||
const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
const bar = barRef.current
|
||||||
|
const primary = bar?.querySelector<HTMLElement> ('.top-nav-primary')
|
||||||
|
if (layout == null || bar == null || primary == null)
|
||||||
|
return
|
||||||
|
|
||||||
addEventListener ('resize', onResize)
|
const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
|
||||||
return () => {
|
let raf = 0
|
||||||
cancelAnimationFrame (raf)
|
let active = true
|
||||||
removeEventListener ('resize', onResize)
|
|
||||||
|
const update = () => {
|
||||||
|
if (!(window.matchMedia ('(min-width: 768px)').matches))
|
||||||
|
{
|
||||||
|
setDesktop (false)
|
||||||
|
setDensity ('auto')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure the same desktop elements while the mobile layout is active.
|
||||||
|
layout.setAttribute ('data-measuring', '')
|
||||||
|
const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
|
||||||
|
const fittingDensity = (() => {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (const candidate of ['auto', 'comfortable', 'compact'] as const)
|
||||||
|
{
|
||||||
|
layout.setAttribute ('data-density', candidate)
|
||||||
|
const style = getComputedStyle (bar)
|
||||||
|
const required = primary.getBoundingClientRect ().width
|
||||||
|
+ (userLink?.getBoundingClientRect ().width ?? 0)
|
||||||
|
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
|
||||||
|
+ (userLink == null ? 0 : parseFloat (style.columnGap))
|
||||||
|
if (required <= bar.clientWidth)
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
layout.setAttribute ('data-density', previousDensity)
|
||||||
|
layout.removeAttribute ('data-measuring')
|
||||||
|
}
|
||||||
|
}) ()
|
||||||
|
setDensity (fittingDensity ?? 'compact')
|
||||||
|
setDesktop (fittingDensity != null)
|
||||||
|
if (fittingDensity != null)
|
||||||
|
measure (moreVsbl ? -1 : activeIdx)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (!(active))
|
||||||
|
return
|
||||||
|
cancelAnimationFrame (raf)
|
||||||
|
raf = requestAnimationFrame (update)
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer =
|
||||||
|
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
|
||||||
|
observer?.observe (bar)
|
||||||
|
observer?.observe (primary)
|
||||||
|
if (userLink != null)
|
||||||
|
observer?.observe (userLink)
|
||||||
|
update ()
|
||||||
|
addEventListener ('resize', schedule)
|
||||||
|
document.fonts?.addEventListener ('loadingdone', schedule)
|
||||||
|
void document.fonts?.ready.then (schedule)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
cancelAnimationFrame (raf)
|
||||||
|
observer?.disconnect ()
|
||||||
|
removeEventListener ('resize', schedule)
|
||||||
|
document.fonts?.removeEventListener ('loadingdone', schedule)
|
||||||
|
}
|
||||||
|
}, [activeIdx, moreVsbl, desktop, density, user?.name])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||||
@@ -215,19 +296,22 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
}, [activeIdx, location])
|
}, [activeIdx, location])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
||||||
<nav className="top-nav-root px-3 flex justify-between items-center w-full">
|
data-desktop={desktop} data-density={density}>
|
||||||
<div className="flex items-center gap-2 h-12">
|
<nav ref={barRef}
|
||||||
|
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
|
||||||
|
<div className="top-nav-primary flex shrink-0 items-center h-12">
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to="/posts"
|
to="/posts"
|
||||||
className="top-nav-brand-link mx-4 text-xl font-bold"
|
className="top-nav-brand-link shrink-0 whitespace-nowrap font-bold"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
scroll (0, 0)
|
scroll (0, 0)
|
||||||
}}>
|
}}>
|
||||||
ぼざクリ タグ広場
|
ぼざクリ タグ広場
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
|
|
||||||
<div ref={navRef} className="relative hidden md:flex h-12 items-center">
|
<div ref={navRef}
|
||||||
|
className="top-nav-desktop-menu relative shrink-0 h-12 items-center">
|
||||||
<div aria-hidden
|
<div aria-hidden
|
||||||
className={cn ('absolute inset-y-0 h-12',
|
className={cn ('absolute inset-y-0 h-12',
|
||||||
'top-nav-highlight',
|
'top-nav-highlight',
|
||||||
@@ -239,6 +323,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
{visibleMenu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={item.to}
|
key={item.to}
|
||||||
|
className="shrink-0 whitespace-nowrap"
|
||||||
{...(animationsOff
|
{...(animationsOff
|
||||||
? { }
|
? { }
|
||||||
: { layoutId: `menu-${ item.name }` })}
|
: { layoutId: `menu-${ item.name }` })}
|
||||||
@@ -252,7 +337,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
ref={(el: (HTMLAnchorElement | null)) => {
|
ref={(el: (HTMLAnchorElement | null)) => {
|
||||||
itemsRef.current[i] = el
|
itemsRef.current[i] = el
|
||||||
}}
|
}}
|
||||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
|
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
||||||
(i === openItemIdx) && 'font-bold')}>
|
(i === openItemIdx) && 'font-bold')}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
@@ -267,7 +352,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
setMoreVsbl (true)
|
setMoreVsbl (true)
|
||||||
measure (-1)
|
measure (-1)
|
||||||
}}
|
}}
|
||||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
|
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
||||||
|
'shrink-0 whitespace-nowrap',
|
||||||
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
||||||
その他 »
|
その他 »
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
@@ -278,7 +364,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4"
|
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||||
|
ml-auto border-0 bg-transparent pr-4"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMenuOpen (!(menuOpen))
|
setMenuOpen (!(menuOpen))
|
||||||
}}>
|
}}>
|
||||||
@@ -291,7 +378,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
key="submenu-shell"
|
key="submenu-shell"
|
||||||
{...(animationsOff ? { }
|
{...(animationsOff ? { }
|
||||||
: { layout: true })}
|
: { layout: true })}
|
||||||
className="relative z-20 hidden md:block overflow-hidden top-nav-submenu"
|
className="top-nav-desktop-submenu relative z-20 shrink-0
|
||||||
|
overflow-hidden top-nav-submenu"
|
||||||
animate={{ height: submenuHeight }}
|
animate={{ height: submenuHeight }}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (moreVsbl)
|
if (moreVsbl)
|
||||||
@@ -305,7 +393,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? (
|
? (
|
||||||
moreMenu.map ((item, i) => (
|
moreMenu.map ((item, i) => (
|
||||||
<div key={i} className="relative h-[40px]">
|
<div key={i} className="relative h-[40px]">
|
||||||
<div className="absolute inset-0 flex items-center px-3">
|
<div className="absolute inset-0 flex items-center px-3
|
||||||
|
overflow-x-auto whitespace-nowrap">
|
||||||
<motion.div
|
<motion.div
|
||||||
transition={topNavTransition}
|
transition={topNavTransition}
|
||||||
{...(animationsOff
|
{...(animationsOff
|
||||||
@@ -315,7 +404,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||||
animate: { x: 0, y: 0, opacity: 1 },
|
animate: { x: 0, y: 0, opacity: 1 },
|
||||||
exit: { x: 40, y: -40, opacity: 0 } }))}
|
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||||
className="z-10 h-full flex items-center px-3 font-bold w-28">
|
className="z-10 h-full flex shrink-0 items-center px-3 font-bold w-28">
|
||||||
<h2>{item.name}</h2>
|
<h2>{item.name}</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
{item.subMenu
|
{item.subMenu
|
||||||
@@ -363,7 +452,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
<div className="relative h-[40px]">
|
<div className="relative h-[40px]">
|
||||||
{animationsOff
|
{animationsOff
|
||||||
? (
|
? (
|
||||||
<div className="absolute inset-0 flex items-center px-3">
|
<div className="absolute inset-0 flex items-center px-3
|
||||||
|
overflow-x-auto whitespace-nowrap">
|
||||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||||
.filter (item => item.visible ?? true)
|
.filter (item => item.visible ?? true)
|
||||||
.map ((item, i) => (
|
.map ((item, i) => (
|
||||||
@@ -392,7 +482,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||||
centre: { y: 0, opacity: 1 },
|
centre: { y: 0, opacity: 1 },
|
||||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||||
className="absolute inset-0 flex items-center px-3"
|
className="absolute inset-0 flex items-center px-3
|
||||||
|
overflow-x-auto whitespace-nowrap"
|
||||||
initial="enter"
|
initial="enter"
|
||||||
animate="centre"
|
animate="centre"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
@@ -432,7 +523,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? (
|
? (
|
||||||
menuOpen && (
|
menuOpen && (
|
||||||
<div
|
<div
|
||||||
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
|
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
|
||||||
'items-start')}>
|
'items-start')}>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
{visibleMenu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
@@ -476,9 +567,6 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
</Fragment>))}
|
</Fragment>))}
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to="/more"
|
to="/more"
|
||||||
ref={(el: (HTMLAnchorElement | null)) => {
|
|
||||||
itemsRef.current[visibleMenu.length] = el
|
|
||||||
}}
|
|
||||||
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
||||||
((openItemIdx < 0)
|
((openItemIdx < 0)
|
||||||
&& 'top-nav-mobile-active font-bold'))}>
|
&& 'top-nav-mobile-active font-bold'))}>
|
||||||
@@ -492,7 +580,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="spmenu"
|
key="spmenu"
|
||||||
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
|
className={cn ('top-nav-root top-nav-mobile-menu flex flex-col',
|
||||||
'items-start')}
|
'items-start')}
|
||||||
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
|
||||||
height: 0 },
|
height: 0 },
|
||||||
@@ -558,9 +646,6 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
</Fragment>))}
|
</Fragment>))}
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to="/more"
|
to="/more"
|
||||||
ref={(el: (HTMLAnchorElement | null)) => {
|
|
||||||
itemsRef.current[visibleMenu.length] = el
|
|
||||||
}}
|
|
||||||
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
||||||
((openItemIdx < 0)
|
((openItemIdx < 0)
|
||||||
&& 'top-nav-mobile-active font-bold'))}>
|
&& 'top-nav-mobile-active font-bold'))}>
|
||||||
@@ -570,7 +655,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
<Separator/>
|
<Separator/>
|
||||||
</motion.div>)}
|
</motion.div>)}
|
||||||
</AnimatePresence>)}
|
</AnimatePresence>)}
|
||||||
</>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TopNav
|
export default TopNav
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
const className = cn ((sp
|
const className = cn ((sp
|
||||||
? 'md:hidden w-full min-h-[40px] flex items-center pl-8'
|
? 'top-nav-user-mobile w-full min-h-[40px] flex items-center pl-8'
|
||||||
: 'hidden md:block ml-auto pr-4'),
|
: 'top-nav-user-desktop shrink-0 whitespace-nowrap ml-auto'),
|
||||||
'font-bold text-red-600 hover:text-red-400',
|
'font-bold text-red-600 hover:text-red-400',
|
||||||
'dark:text-yellow-400 dark:hover:text-yellow-200')
|
'dark:text-yellow-400 dark:hover:text-yellow-200')
|
||||||
|
|
||||||
@@ -30,4 +30,4 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
|
|||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TopNavUser
|
export default TopNavUser
|
||||||
|
|||||||
+233
-1
@@ -197,12 +197,244 @@ body
|
|||||||
background: var(--top-nav-root-bg-mobile);
|
background: var(--top-nav-root-bg-mobile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-nav-bar
|
||||||
|
{
|
||||||
|
padding-inline: 8px;
|
||||||
|
column-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-primary
|
||||||
|
{
|
||||||
|
column-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout .top-nav-brand-link
|
||||||
|
{
|
||||||
|
margin-inline: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-menu,
|
||||||
|
.top-nav-desktop-submenu,
|
||||||
|
.top-nav-user-desktop
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-menu
|
||||||
|
{
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-menu .top-nav-menu-link
|
||||||
|
{
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-user-desktop
|
||||||
|
{
|
||||||
|
padding-right: 4px;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-user-mobile
|
||||||
|
{
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px)
|
@media (min-width: 768px)
|
||||||
{
|
{
|
||||||
.top-nav-root
|
.top-nav-layout .top-nav-brand-link
|
||||||
|
{
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-root
|
||||||
{
|
{
|
||||||
background: var(--top-nav-root-bg-desktop);
|
background: var(--top-nav-root-bg-desktop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-desktop-menu,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-desktop-menu
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-user-desktop,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-user-desktop,
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-desktop-submenu
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-mobile-toggle,
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-mobile-menu,
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-user-mobile
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-measuring]
|
||||||
|
{
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px)
|
||||||
|
{
|
||||||
|
.top-nav-bar
|
||||||
|
{
|
||||||
|
padding-inline: 12px;
|
||||||
|
column-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-primary
|
||||||
|
{
|
||||||
|
column-gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout .top-nav-brand-link
|
||||||
|
{
|
||||||
|
margin-inline: 8px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-menu
|
||||||
|
{
|
||||||
|
column-gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-menu,
|
||||||
|
.top-nav-user-desktop
|
||||||
|
{
|
||||||
|
font-size: .9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-menu .top-nav-menu-link
|
||||||
|
{
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-user-desktop
|
||||||
|
{
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px)
|
||||||
|
{
|
||||||
|
.top-nav-bar
|
||||||
|
{
|
||||||
|
column-gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-primary
|
||||||
|
{
|
||||||
|
column-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout .top-nav-brand-link
|
||||||
|
{
|
||||||
|
margin-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-menu
|
||||||
|
{
|
||||||
|
column-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-menu,
|
||||||
|
.top-nav-user-desktop
|
||||||
|
{
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-menu .top-nav-menu-link
|
||||||
|
{
|
||||||
|
padding-inline: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-user-desktop
|
||||||
|
{
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='comfortable'] .top-nav-bar
|
||||||
|
{
|
||||||
|
padding-inline: 12px;
|
||||||
|
column-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='comfortable'] .top-nav-primary
|
||||||
|
{
|
||||||
|
column-gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='comfortable'] .top-nav-brand-link
|
||||||
|
{
|
||||||
|
margin-inline: 8px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='comfortable'] .top-nav-desktop-menu
|
||||||
|
{
|
||||||
|
column-gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='comfortable'] .top-nav-desktop-menu,
|
||||||
|
.top-nav-layout[data-density='comfortable'] .top-nav-user-desktop
|
||||||
|
{
|
||||||
|
font-size: .9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='comfortable'] .top-nav-desktop-menu .top-nav-menu-link
|
||||||
|
{
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='comfortable'] .top-nav-user-desktop
|
||||||
|
{
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='compact'] .top-nav-bar
|
||||||
|
{
|
||||||
|
padding-inline: 8px;
|
||||||
|
column-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='compact'] .top-nav-primary
|
||||||
|
{
|
||||||
|
column-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='compact'] .top-nav-brand-link
|
||||||
|
{
|
||||||
|
margin-inline: 4px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='compact'] .top-nav-desktop-menu
|
||||||
|
{
|
||||||
|
column-gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='compact'] .top-nav-desktop-menu,
|
||||||
|
.top-nav-layout[data-density='compact'] .top-nav-user-desktop
|
||||||
|
{
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='compact'] .top-nav-desktop-menu .top-nav-menu-link
|
||||||
|
{
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-density='compact'] .top-nav-user-desktop
|
||||||
|
{
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-nav-brand-link,
|
.top-nav-brand-link,
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createHelpFixture } from '@/test/helpFixtures'
|
||||||
|
|
||||||
|
|
||||||
|
describe ('Help file catalogue', () => {
|
||||||
|
it.each ([['Éclair', 'eclair'], ['home', 'HOME'], ['e\u0301clair', 'Éclair']]) (
|
||||||
|
'compares title %s and %s equally at primary strength', (a, b) => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (api.compareHelpTitles (a, b)).toBe (0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('orders distinct Latin titles in both directions', () => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (api.compareHelpTitles ('Apple', 'Zebra')).toBeLessThan (0)
|
||||||
|
expect (api.compareHelpTitles ('Zebra', 'Apple')).toBeGreaterThan (0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each ([['ほーむ', 'ホーム'], ['ホーム', 'ほーむ'], ['ホーム', 'ホーム'],
|
||||||
|
['ABC', 'abc'], ['ガイド', 'がいど']]) (
|
||||||
|
'normalises search text %s and %s identically', (query, text) => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (api.normaliseHelpSearchText (query)).toBe (api.normaliseHelpSearchText (text))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each ([['2026-10-02', null], ['2026-10-02.2', 2],
|
||||||
|
['2026-10-02.3', 3], ['2026-10-02.10', 10]] as const) (
|
||||||
|
'separates the date and revision of %s', (edition, revision) => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (api.parseHelpEdition (edition)).toEqual ({ date: '2026-10-02', revision })
|
||||||
|
const date = api.helpEditionDate (edition)!
|
||||||
|
expect ([date.getFullYear (), date.getMonth (), date.getDate ()]).toEqual ([2026, 9, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each (['missing', '2026-02-30', '2026-10-02.0', '2026-10-02.2.extra',
|
||||||
|
'2026-10-02.9007199254740992']) ('rejects invalid edition %s', edition => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (api.parseHelpEdition (edition)).toBeNull ()
|
||||||
|
expect (api.helpEditionDate (edition)).toBeNull ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('compares numeric revision suffixes without losing the edition identifiers', () => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (['2026-10-02.10', '2026-10-02.3', '2026-10-02', '2026-10-02.2']
|
||||||
|
.sort (api.compareHelpEditions)).toEqual ([
|
||||||
|
'2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition)
|
||||||
|
.toBe ('2026-10-02.10')
|
||||||
|
expect (await api.getHelpSearchEntries ('ja')).toEqual ([
|
||||||
|
{ identifier: 'guide', edition: '2026-10-02.10',
|
||||||
|
title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' },
|
||||||
|
{ identifier: '設定 手順', edition: '2026-10-03',
|
||||||
|
title: '設定', body: '\nタグを設定する' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('orders published editions numerically and separates languages', async () => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (await api.getHelpEditions ('guide', 'ja')).toEqual ([
|
||||||
|
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
|
||||||
|
expect (await api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
|
||||||
|
expect (await api.getHelpPublishedEditions ('guide', 'ja'))
|
||||||
|
.not.toContain ('2026-10-02.11')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('uses generated edition titles without reparsing raw sources', async () => {
|
||||||
|
const { api, raw } = createHelpFixture ()
|
||||||
|
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
|
||||||
|
.toEqual ({ title: '旧ホーム', body: '\n過去限定本文' })
|
||||||
|
raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||||
|
.mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
|
||||||
|
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-02'))
|
||||||
|
.toEqual ({ title: 'ホーム', body: '\nタグを検索する' })
|
||||||
|
expect (raw['../../help/guide/editions/ja/2026-10-02.mdx']).not.toHaveBeenCalled ()
|
||||||
|
expect ((await api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))?.source)
|
||||||
|
.toBe ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('returns missing resources without substituting another edition', async () => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
expect (await api.getHelpArticle ('absent')).toBeNull ()
|
||||||
|
expect (await api.getHelpEditions ('absent', 'ja')).toEqual ([])
|
||||||
|
expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull ()
|
||||||
|
expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('reports generated search asset failures instead of hiding articles', async () => {
|
||||||
|
const { api, loads } = createHelpFixture ()
|
||||||
|
loads.search.mockImplementationOnce (() => { throw new Error ('asset failure') })
|
||||||
|
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('asset failure')
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('reports raw loading failures and excludes unpublished editions', async () => {
|
||||||
|
const { api, raw } = createHelpFixture ()
|
||||||
|
const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||||
|
loader.mockRejectedValueOnce (new Error ('fixture failure'))
|
||||||
|
await expect (api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))
|
||||||
|
.rejects.toThrow ('fixture failure')
|
||||||
|
expect (await api.getHelpEdition ('guide', 'ja', '2026-10-02.11')).toBeNull ()
|
||||||
|
expect (await api.getHelpEditionSource ('guide', 'ja', '2026-10-02.11')).toBeNull ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('compares raw H1 and body lines in the requested direction', async () => {
|
||||||
|
const { api } = createHelpFixture ()
|
||||||
|
const forward = await api.getHelpDiff ('guide', 'ja', '2026-10-01', '2026-10-02.10')
|
||||||
|
expect (forward?.from.title).toBe ('旧ホーム')
|
||||||
|
expect (forward?.to.title).toBe ('ホーム第十版')
|
||||||
|
expect (forward?.lines).toEqual (expect.arrayContaining ([
|
||||||
|
{ type: 'removed', content: '# 旧ホーム\n' },
|
||||||
|
{ type: 'added', content: '# ホーム第十版\n' },
|
||||||
|
{ type: 'unchanged', content: '\n' }]))
|
||||||
|
const reverse = await api.getHelpDiff ('guide', 'ja', '2026-10-02.10', '2026-10-01')
|
||||||
|
expect (reverse?.lines).toEqual (expect.arrayContaining ([
|
||||||
|
{ type: 'added', content: '# 旧ホーム\n' },
|
||||||
|
{ type: 'removed', content: '# ホーム第十版\n' }]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each ([['missing', 'ja', '2026-10-01', '2026-10-02.10'],
|
||||||
|
['guide', 'ja', '2026-10-01', '2026-10-02.11'],
|
||||||
|
['guide', 'en', '2026-10-01', '2026-10-02']]) (
|
||||||
|
'rejects invalid diff editions for %s/%s without reading sources',
|
||||||
|
async (id, language, from, to) => {
|
||||||
|
const { api, raw } = createHelpFixture ()
|
||||||
|
expect (await api.getHelpDiff (id, language, from, to)).toBeNull ()
|
||||||
|
Object.values (raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { ComponentType } from 'react'
|
||||||
|
|
||||||
|
type ArticleLanguage = { currentEdition: string; publishedEditions: string[] }
|
||||||
|
|
||||||
|
export type ArticleMeta = {
|
||||||
|
identifier: string
|
||||||
|
canonicalLanguage: string
|
||||||
|
languages: Record<string, ArticleLanguage> }
|
||||||
|
|
||||||
|
export type HelpEditionText = {
|
||||||
|
title: string
|
||||||
|
body: string }
|
||||||
|
|
||||||
|
export type HelpSearchEntry = HelpEditionText & {
|
||||||
|
identifier: string
|
||||||
|
edition: string }
|
||||||
|
|
||||||
|
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
|
||||||
|
|
||||||
|
export type HelpEditionSource = HelpEditionText & { source: string }
|
||||||
|
export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & {
|
||||||
|
pred: string | null
|
||||||
|
change: 'new' | 'update'
|
||||||
|
date: string }
|
||||||
|
export type HelpTitleIndex = Record<string, string[]>
|
||||||
|
export type HelpDiffLine = {
|
||||||
|
type: 'added' | 'removed' | 'unchanged'
|
||||||
|
content: string }
|
||||||
|
export type HelpDiff = {
|
||||||
|
from: HelpEditionSource
|
||||||
|
to: HelpEditionSource
|
||||||
|
lines: HelpDiffLine[] }
|
||||||
|
|
||||||
|
export type HelpEditionIdentifier = { date: string; revision: number | null }
|
||||||
|
|
||||||
|
// Fix the locale and primary strength; ICU versions still differ from MySQL UCA 9.0.
|
||||||
|
const helpTitleCollator = new Intl.Collator ('en', {
|
||||||
|
usage: 'sort', sensitivity: 'base', ignorePunctuation: false, numeric: false })
|
||||||
|
|
||||||
|
|
||||||
|
export const compareHelpTitles = (a: string, b: string): number =>
|
||||||
|
helpTitleCollator.compare (a, b)
|
||||||
|
|
||||||
|
|
||||||
|
export const normaliseHelpSearchText = (value: string): string =>
|
||||||
|
value.normalize ('NFKC').toLowerCase ().replace (/[ァ-ヶヽヾ]/g,
|
||||||
|
character => String.fromCodePoint (character.codePointAt (0)! - 0x60))
|
||||||
|
|
||||||
|
|
||||||
|
export const parseHelpEdition = (edition: string): HelpEditionIdentifier | null => {
|
||||||
|
const matched = edition.match (/^(\d{4}-\d{2}-\d{2})(?:\.([1-9]\d*))?$/)
|
||||||
|
if (matched == null)
|
||||||
|
return null
|
||||||
|
const date = new Date (matched[1])
|
||||||
|
const revision = matched[2] == null ? null : Number (matched[2])
|
||||||
|
if (Number.isNaN (date.getTime ()) || date.toISOString ().slice (0, 10) !== matched[1]
|
||||||
|
|| (revision != null && !(Number.isSafeInteger (revision))))
|
||||||
|
return null
|
||||||
|
return { date: matched[1], revision }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const compareHelpEditions = (a: string, b: string): number => {
|
||||||
|
const before = parseHelpEdition (a)
|
||||||
|
const after = parseHelpEdition (b)
|
||||||
|
if (before == null || after == null)
|
||||||
|
return a.localeCompare (b)
|
||||||
|
return before.date.localeCompare (after.date)
|
||||||
|
|| (before.revision ?? 1) - (after.revision ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const helpEditionDate = (edition: string): Date | null => {
|
||||||
|
const parsed = parseHelpEdition (edition)
|
||||||
|
return parsed == null ? null : new Date (`${ parsed.date }T00:00:00`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpArticle = async (identifier: string): Promise<ArticleMeta | null> => {
|
||||||
|
const { default: articles } = await import ('virtual:help/articles')
|
||||||
|
return Object.hasOwn (articles, identifier) ? articles[identifier] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpEditions = async (
|
||||||
|
identifier: string,
|
||||||
|
language: string,
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const article = await getHelpArticle (identifier)
|
||||||
|
return article?.languages[language]?.publishedEditions ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpPublishedEditions = async (
|
||||||
|
identifier: string,
|
||||||
|
language: string,
|
||||||
|
): Promise<string[]> => await getHelpEditions (identifier, language)
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpEdition = async (
|
||||||
|
identifier: string,
|
||||||
|
language: string,
|
||||||
|
edition: string,
|
||||||
|
): Promise<ComponentType | null> => {
|
||||||
|
const { components } = await import ('virtual:help/loaders')
|
||||||
|
const loader = components[`${ identifier }/${ language }/${ edition }`]
|
||||||
|
|
||||||
|
if (!(loader))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (await loader ()).default
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpEditionSource = async (
|
||||||
|
identifier: string,
|
||||||
|
language: string,
|
||||||
|
edition: string,
|
||||||
|
): Promise<HelpEditionSource | null> => {
|
||||||
|
const { sources } = await import ('virtual:help/loaders')
|
||||||
|
const loader = sources[`${ identifier }/${ language }/${ edition }`]
|
||||||
|
|
||||||
|
if (!(loader))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const [source, text] = await Promise.all ([
|
||||||
|
loader (), getHelpEditionText (identifier, language, edition)])
|
||||||
|
return text == null ? null : { source: source.default, ...text }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpEditionText = async (
|
||||||
|
identifier: string,
|
||||||
|
language: string,
|
||||||
|
edition: string,
|
||||||
|
): Promise<HelpEditionText | null> => {
|
||||||
|
const { texts } = await import ('virtual:help/loaders')
|
||||||
|
const loader = texts[`${ identifier }/${ language }/${ edition }`]
|
||||||
|
return loader == null ? null : (await loader ()).default
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpSearchEntries = async (
|
||||||
|
language: string,
|
||||||
|
): Promise<HelpSearchEntry[]> => {
|
||||||
|
const { default: search } = await import ('virtual:help/search')
|
||||||
|
return search[language] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpEditionContent = async (
|
||||||
|
identifier: string,
|
||||||
|
language: string,
|
||||||
|
edition: string,
|
||||||
|
): Promise<HelpEditionContent | null> => {
|
||||||
|
const [content, text] = await Promise.all ([
|
||||||
|
getHelpEdition (identifier, language, edition),
|
||||||
|
getHelpEditionText (identifier, language, edition)])
|
||||||
|
|
||||||
|
return content != null && text != null ? { content, ...text } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
|
||||||
|
const { default: changes } = await import ('virtual:help/changes')
|
||||||
|
return changes[language] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpTitleIndex = async (language: string): Promise<HelpTitleIndex> => {
|
||||||
|
const { default: titles } = await import ('virtual:help/titles')
|
||||||
|
return titles[language] ?? { }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const diffHelpLines = (from: string, to: string): HelpDiffLine[] => {
|
||||||
|
const before = from.match (/[^\n]*\n|[^\n]+$/g) ?? []
|
||||||
|
const after = to.match (/[^\n]*\n|[^\n]+$/g) ?? []
|
||||||
|
const lengths = Array.from ({ length: before.length + 1 },
|
||||||
|
() => new Uint32Array (after.length + 1))
|
||||||
|
|
||||||
|
for (let i = before.length - 1; i >= 0; --i)
|
||||||
|
{
|
||||||
|
for (let j = after.length - 1; j >= 0; --j)
|
||||||
|
{
|
||||||
|
lengths[i][j] =
|
||||||
|
before[i] === after[j]
|
||||||
|
? lengths[i + 1][j + 1] + 1
|
||||||
|
: Math.max (lengths[i + 1][j], lengths[i][j + 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: HelpDiffLine[] = []
|
||||||
|
let i = 0
|
||||||
|
let j = 0
|
||||||
|
while (i < before.length || j < after.length)
|
||||||
|
{
|
||||||
|
if (i < before.length && j < after.length && before[i] === after[j])
|
||||||
|
{
|
||||||
|
lines.push ({ type: 'unchanged', content: before[i] })
|
||||||
|
++i
|
||||||
|
++j
|
||||||
|
}
|
||||||
|
else if (i < before.length
|
||||||
|
&& (j === after.length || lengths[i + 1][j] >= lengths[i][j + 1]))
|
||||||
|
{
|
||||||
|
lines.push ({ type: 'removed', content: before[i] })
|
||||||
|
++i
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lines.push ({ type: 'added', content: after[j] })
|
||||||
|
++j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getHelpDiff = async (
|
||||||
|
identifier: string,
|
||||||
|
language: string,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): Promise<HelpDiff | null> => {
|
||||||
|
const editions = await getHelpPublishedEditions (identifier, language)
|
||||||
|
if (!(editions.includes (from)) || !(editions.includes (to)))
|
||||||
|
return null
|
||||||
|
|
||||||
|
const [before, after] = await Promise.all ([
|
||||||
|
getHelpEditionSource (identifier, language, from),
|
||||||
|
getHelpEditionSource (identifier, language, to)])
|
||||||
|
if (before == null || after == null)
|
||||||
|
return null
|
||||||
|
|
||||||
|
return { from: before, to: after, lines: diffHelpLines (before.source, after.source) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import * as prefetchers from '@/lib/prefetchers'
|
||||||
|
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||||
|
import HelpDiffPage from '@/pages/help/HelpDiffPage'
|
||||||
|
import { helpFixture } from '@/test/helpFixtures'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
const settings = vi.hoisted (() => ({ linkPreload: 'intent' }))
|
||||||
|
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
||||||
|
useClientBehaviourSettings: () => settings }))
|
||||||
|
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
|
||||||
|
|
||||||
|
|
||||||
|
const Location = () => {
|
||||||
|
const location = useLocation ()
|
||||||
|
return <output data-testid="location">{location.pathname}</output>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
describe ('Help prefetch integration', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
helpFixture.reset ()
|
||||||
|
settings.linkPreload = 'intent'
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each (['/help', '/help?title=ホーム&text=検索&page=2', '/help/guide', '/help/changes',
|
||||||
|
'/help/guide/diff?from=2026-10-01&to=2026-10-02.10',
|
||||||
|
'/help/%E8%A8%AD%E5%AE%9A%20%E6%89%8B%E9%A0%86?edition=2026-10-03']) (
|
||||||
|
'registers a unique prefetcher for %s', path => {
|
||||||
|
const url = new URL (path, window.location.origin)
|
||||||
|
expect (prefetchers.routePrefetchers.filter (entry => entry.test (url))).toHaveLength (1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('loads and reuses the same MDX module for repeated prefetch and display', async () => {
|
||||||
|
const { queryClient } = renderWithProviders (
|
||||||
|
<>
|
||||||
|
<PrefetchLink to="/help/guide">Help</PrefetchLink>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={null}/>
|
||||||
|
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||||
|
</Routes>
|
||||||
|
</>)
|
||||||
|
const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||||
|
await Promise.all ([
|
||||||
|
prefetchers.prefetchForURL (queryClient, '/help/guide'),
|
||||||
|
prefetchers.prefetchForURL (queryClient, '/help/guide')])
|
||||||
|
expect (loader).toHaveBeenCalledTimes (1)
|
||||||
|
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
|
||||||
|
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||||
|
expect (loader).toHaveBeenCalledTimes (1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('prefetches the requested identifier and edition independently', async () => {
|
||||||
|
const client = new QueryClient ()
|
||||||
|
await prefetchers.prefetchForURL (client, '/help/guide?edition=2026-10-02.3')
|
||||||
|
await prefetchers.prefetchForURL (client, `/help/${encodeURIComponent ('設定 手順')}`)
|
||||||
|
expect (helpFixture.modules['../../help/guide/editions/ja/2026-10-02.3.mdx'])
|
||||||
|
.toHaveBeenCalledTimes (1)
|
||||||
|
expect (helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx'])
|
||||||
|
.toHaveBeenCalledTimes (1)
|
||||||
|
expect (helpFixture.modules['../../help/guide/editions/en/2026-10-02.mdx'])
|
||||||
|
.not.toHaveBeenCalled ()
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('loads history editions without treating changes as an article', async () => {
|
||||||
|
const article = vi.spyOn (helpFixture.api, 'getHelpArticle')
|
||||||
|
await prefetchers.prefetchForURL (new QueryClient (), '/help/changes')
|
||||||
|
expect (article).not.toHaveBeenCalledWith ('changes')
|
||||||
|
expect (helpFixture.loads.changes).toHaveBeenCalledTimes (1)
|
||||||
|
Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||||
|
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('reuses two prefetched raw editions when displaying their diff', async () => {
|
||||||
|
const path = '/help/guide/diff?from=2026-10-01&to=2026-10-02.10'
|
||||||
|
const { queryClient } = renderWithProviders (
|
||||||
|
<>
|
||||||
|
<PrefetchLink to={path}>diff</PrefetchLink>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={null}/>
|
||||||
|
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
|
||||||
|
</Routes>
|
||||||
|
</>)
|
||||||
|
await Promise.all ([prefetchers.prefetchForURL (queryClient, path),
|
||||||
|
prefetchers.prefetchForURL (queryClient, path)])
|
||||||
|
fireEvent.click (screen.getByRole ('link', { name: 'diff' }))
|
||||||
|
expect (await screen.findByRole ('link', { name: '旧ホーム (2026-10-01)' }))
|
||||||
|
.toHaveAttribute ('href', '/help/guide?edition=2026-10-01')
|
||||||
|
expect (screen.getByText ('+ # ホーム第十版')).toBeInTheDocument ()
|
||||||
|
Object.entries (helpFixture.raw).forEach (([name, load]) => {
|
||||||
|
const expected = /\/ja\/(2026-10-01|2026-10-02\.10)\.mdx$/.test (name) ? 1 : 0
|
||||||
|
expect (load).toHaveBeenCalledTimes (expected)
|
||||||
|
})
|
||||||
|
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each (['intent', 'off']) ('respects %s intent settings and still prefetches clicks',
|
||||||
|
async mode => {
|
||||||
|
settings.linkPreload = mode
|
||||||
|
const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue ()
|
||||||
|
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
|
||||||
|
const link = screen.getByRole ('link', { name: 'Help' })
|
||||||
|
fireEvent.mouseEnter (link)
|
||||||
|
fireEvent.touchStart (link)
|
||||||
|
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 2 : 0)
|
||||||
|
fireEvent.click (link)
|
||||||
|
await waitFor (() => expect (screen.getByTestId ('location'))
|
||||||
|
.toHaveTextContent ('/help/guide'))
|
||||||
|
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 3 : 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('continues navigation after prefetch failure', async () => {
|
||||||
|
vi.spyOn (console, 'error').mockImplementation (() => {})
|
||||||
|
vi.spyOn (prefetchers, 'prefetchForURL').mockRejectedValue (new Error ('offline'))
|
||||||
|
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
|
||||||
|
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
|
||||||
|
await waitFor (() => expect (screen.getByTestId ('location'))
|
||||||
|
.toHaveTextContent ('/help/guide'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query'
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
import { match } from 'path-to-regexp'
|
import { match } from 'path-to-regexp'
|
||||||
|
|
||||||
|
import { getHelpArticle,
|
||||||
|
getHelpChanges,
|
||||||
|
getHelpDiff,
|
||||||
|
getHelpEditionContent,
|
||||||
|
getHelpSearchEntries } from '@/lib/help'
|
||||||
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
|
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
|
||||||
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
||||||
import { fetchWikiPage,
|
import { fetchWikiPage,
|
||||||
fetchWikiPageByTitle,
|
fetchWikiPageByTitle,
|
||||||
@@ -15,12 +20,69 @@ type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
|
|||||||
const mPost = match<{ id: string }> ('/posts/:id')
|
const mPost = match<{ id: string }> ('/posts/:id')
|
||||||
const mWiki = match<{ title: string }> ('/wiki/:title')
|
const mWiki = match<{ title: string }> ('/wiki/:title')
|
||||||
const mTag = match<{ id: string }> ('/tags/:id')
|
const mTag = match<{ id: string }> ('/tags/:id')
|
||||||
|
const mHelp = match<{ identifier: string }> ('/help/:identifier')
|
||||||
|
const mHelpDiff = match<{ identifier: string }> ('/help/:identifier/diff')
|
||||||
|
|
||||||
|
|
||||||
const boolFromQuery = (value: string | null): boolean =>
|
const boolFromQuery = (value: string | null): boolean =>
|
||||||
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
||||||
|
|
||||||
|
|
||||||
|
const prefetchHelpIndex: Prefetcher = async qc => {
|
||||||
|
await qc.prefetchQuery ({
|
||||||
|
queryKey: helpKeys.index ('ja'),
|
||||||
|
queryFn: () => getHelpSearchEntries ('ja'),
|
||||||
|
staleTime: Infinity })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const prefetchHelpShow: Prefetcher = async (qc, url) => {
|
||||||
|
const matched = mHelp (url.pathname)
|
||||||
|
if (!(matched))
|
||||||
|
return
|
||||||
|
|
||||||
|
const { identifier } = matched.params
|
||||||
|
const language = 'ja'
|
||||||
|
const article = await qc.fetchQuery ({
|
||||||
|
queryKey: helpKeys.article (identifier),
|
||||||
|
queryFn: () => getHelpArticle (identifier),
|
||||||
|
staleTime: Infinity })
|
||||||
|
const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition
|
||||||
|
if (edition == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
await qc.prefetchQuery ({
|
||||||
|
queryKey: helpKeys.edition (identifier, language, edition),
|
||||||
|
queryFn: () => getHelpEditionContent (identifier, language, edition),
|
||||||
|
staleTime: Infinity })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const prefetchHelpChanges: Prefetcher = async qc => {
|
||||||
|
await qc.prefetchQuery ({
|
||||||
|
queryKey: helpKeys.changes ('ja'),
|
||||||
|
queryFn: () => getHelpChanges ('ja'),
|
||||||
|
staleTime: Infinity })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const prefetchHelpDiff: Prefetcher = async (qc, url) => {
|
||||||
|
const matched = mHelpDiff (url.pathname)
|
||||||
|
const fromValues = url.searchParams.getAll ('from')
|
||||||
|
const toValues = url.searchParams.getAll ('to')
|
||||||
|
const [from] = fromValues
|
||||||
|
const [to] = toValues
|
||||||
|
if (!(matched) || fromValues.length !== 1 || toValues.length !== 1 || !(from) || !(to))
|
||||||
|
return
|
||||||
|
|
||||||
|
const { identifier } = matched.params
|
||||||
|
await qc.prefetchQuery ({
|
||||||
|
queryKey: helpKeys.diff (identifier, 'ja', from, to),
|
||||||
|
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
|
||||||
|
staleTime: Infinity })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
||||||
const title = url.searchParams.get ('title') ?? ''
|
const title = url.searchParams.get ('title') ?? ''
|
||||||
|
|
||||||
@@ -223,6 +285,13 @@ const prefetchTagChanges: Prefetcher = async (qc, url) => {
|
|||||||
|
|
||||||
|
|
||||||
export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
|
export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [
|
||||||
|
{ test: u => /^\/help\/?$/.test (u.pathname), run: prefetchHelpIndex },
|
||||||
|
{ test: u => /^\/help\/changes\/?$/.test (u.pathname), run: prefetchHelpChanges },
|
||||||
|
{ test: u => Boolean (mHelpDiff (u.pathname)), run: prefetchHelpDiff },
|
||||||
|
{ test: u => {
|
||||||
|
const matched = mHelp (u.pathname)
|
||||||
|
return Boolean (matched && matched.params.identifier !== 'changes')
|
||||||
|
}, run: prefetchHelpShow },
|
||||||
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
|
{ test: u => ['/', '/posts', '/posts/search'].includes (u.pathname),
|
||||||
run: prefetchPostsIndex },
|
run: prefetchPostsIndex },
|
||||||
{ test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname))
|
{ test: u => (!(['/posts/new', '/posts/changes', '/posts/search'].includes (u.pathname))
|
||||||
|
|||||||
@@ -61,3 +61,14 @@ export const wikiKeys = {
|
|||||||
root: ['wiki'] as const,
|
root: ['wiki'] as const,
|
||||||
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
|
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
|
||||||
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }
|
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }
|
||||||
|
|
||||||
|
export const helpKeys = {
|
||||||
|
root: ['help'] as const,
|
||||||
|
index: (language: string) => ['help', 'index', language] as const,
|
||||||
|
titles: (language: string) => ['help', 'titles', language] as const,
|
||||||
|
article: (identifier: string) => ['help', 'article', identifier] as const,
|
||||||
|
edition: (identifier: string, language: string, edition: string) =>
|
||||||
|
['help', 'edition', identifier, language, edition] as const,
|
||||||
|
diff: (identifier: string, language: string, from: string, to: string) =>
|
||||||
|
['help', 'diff', identifier, language, from, to] as const,
|
||||||
|
changes: (language: string) => ['help', 'changes', language] as const }
|
||||||
|
|||||||
@@ -72,11 +72,25 @@ describe ('tags API functions', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('returns null when tag fetches fail', async () => {
|
it ('returns null for a missing named tag while retaining the ID helper contract', async () => {
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
|
||||||
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
api.apiGet.mockRejectedValueOnce (new Error ('missing'))
|
||||||
|
api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
|
||||||
|
|
||||||
await expect (fetchTag ('1')).resolves.toBeNull ()
|
await expect (fetchTag ('1')).resolves.toBeNull ()
|
||||||
await expect (fetchTagByName ('unknown')).resolves.toBeNull ()
|
await expect (fetchTagByName ('unknown')).resolves.toBeNull ()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each ([403, 500]) ('propagates named tag HTTP %s', async status => {
|
||||||
|
const error = { isAxiosError: true, response: { status } }
|
||||||
|
api.apiGet.mockRejectedValueOnce (error)
|
||||||
|
await expect (fetchTagByName ('ホーム')).rejects.toBe (error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('propagates named tag network failures and encodes the name', async () => {
|
||||||
|
const error = new Error ('offline')
|
||||||
|
api.apiGet.mockRejectedValueOnce (error)
|
||||||
|
await expect (fetchTagByName ('名前/#%')).rejects.toBe (error)
|
||||||
|
expect (api.apiGet).toHaveBeenLastCalledWith (
|
||||||
|
`/tags/name/${ encodeURIComponent ('名前/#%') }`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
|
||||||
import type { Deerjikist,
|
import type { Deerjikist,
|
||||||
@@ -68,11 +70,13 @@ export const fetchExternalTag = async (id: string): Promise<Tag | null> => {
|
|||||||
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
export const fetchTagByName = async (name: string): Promise<Tag | null> => {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await apiGet (`/tags/name/${ name }`)
|
return await apiGet (`/tags/name/${ encodeURIComponent (name) }`)
|
||||||
}
|
}
|
||||||
catch
|
catch (error)
|
||||||
{
|
{
|
||||||
return null
|
if (isAxiosError (error) && error.response?.status === 404)
|
||||||
|
return null
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示
新しいイシューから参照
ユーザーをブロックする