コミットを比較

...
10 コミット
作成者 SHA1 メッセージ 日付
みてるぞ f362b915a5 #431 2026-10-16 00:15:21 +09:00
みてるぞ 6ba87beef6 #431 2026-10-15 23:49:38 +09:00
みてるぞ 47c40afcc1 #431 2026-10-15 22:07:07 +09:00
みてるぞ 1e464107a8 #431 2026-10-15 20:20:00 +09:00
みてるぞ c142f08e6c #431 2026-10-15 20:02:34 +09:00
みてるぞ 2ef872bd33 #431 2026-10-15 02:45:41 +09:00
みてるぞ 620a0b5cff #431 2026-10-15 02:29:32 +09:00
みてるぞ e44887cf15 ヘルプを Wiki から分離 (#424) (#430)
画面上でのテストは完了.
移行にす早さが求められるので,準備ができたらマージする.

Reviewed-on: #430
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-10-13 12:25:11 +09:00
みてるぞ 534a66f90d タグ・グループを Wiki から分離 (#425) (#429)
Reviewed-on: #429
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-10-11 03:39:47 +09:00
みてるぞ 592932b5a5 ニコニコ同期の環境変数修正 (#426) (#427)
Reviewed-on: #427
2026-10-07 04:26:04 +09:00
132個のファイルの変更、8267行の追加、2517行の削除
+4 -1
ファイルの表示
@@ -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:)
+1 -1
ファイルの表示
@@ -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 ?"),
+3 -3
ファイルの表示
@@ -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
+12 -7
ファイルの表示
@@ -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
+43
ファイルの表示
@@ -0,0 +1,43 @@
class TagGroupsController < ApplicationController
def index
locale = resolve_locale!
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a)
end
def show
locale = resolve_locale!
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
return head :not_found unless group
render json: TagGroupRepr.one(locale, group)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(locale, group), status: :created
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
group = TagGroup.find_by(id: params[:id])
return head :not_found unless group
TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(locale, group.reload)
end
private
def write_attributes
{ name: params[:name], description: params[:description],
members: params[:members], placements: params[:placements] }
end
end
+1 -1
ファイルの表示
@@ -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)
+25 -43
ファイルの表示
@@ -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)
+244 -146
ファイルの表示
@@ -1,107 +1,126 @@
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!
# TODO: ヘルプおよびタグ・グループ追加後に,
# TODO: タグ説明とヘルプ、タグ・グループから取得する作りに変更する.
#
title = params[:title].to_s.strip
descriptions = TagDescription.where(language_code: 'ja')
.includes(tag: :tag_names)
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
title = params[:title].to_s.strip
descriptions =
TagDescription
.where(language_code: locale.language_code)
.includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc)
tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
if title.present? if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions = descriptions.joins(tag: :tag_names) descriptions =
.where(tag_names: { language_code: 'ja', primary_flg: true }) descriptions.joins(tag: :tag_names)
.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)
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20) tag_groups = tag_groups.where(
'CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?',
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
end end
json = descriptions.map { |description| WikiPageRepr.tag_description(description) } json = descriptions.map { |description| WikiPageRepr.tag_description(locale, description) }
json.concat(WikiPageRepr.base(pages)) json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
json.sort_by! do |row|
[-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')]
end
json = json.first(20) if title.present? 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
page = WikiPage.joins(:tag_name) tag_group = TagGroup.find_by(id: params[:id])
.includes(tag_name: :tag) return render_tag_group locale, tag_group if tag_group
.find_by(id: params[:id])
render_wiki_page_or_404 page head :not_found
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
tag_description = tag_description_by_title(title) if (name = TagGroupRepr.name_from_title(title))
tag_group = TagGroup.find_by(name:)
return render_tag_group locale, tag_group if tag_group
return head :not_found
end
return render_wiki_page_by_tag_description tag_description if tag_description tag_description = tag_description_by_title(locale, title)
page = WikiPage.joins(:tag_name) return render_wiki_page_by_tag_description locale, tag_description if tag_description
.includes(tag_name: :tag)
.find_by(tag_name: { name: title })
render_wiki_page_or_404 page head :not_found
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])
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
return head :no_content if tag_description_by_title(title) if (name = TagGroupRepr.name_from_title(title))
exists = TagGroup.exists?(name:)
return head exists ? :no_content : :not_found
end
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) 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
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) tag_group = TagGroup.find_by(id:)
from_rev = from && page.wiki_revisions.find(from) if tag_group
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision from_rev = from && group_version(tag_group, from)
if ((from_rev && !from_rev.content?) || !to_rev&.content?) to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
return render_unprocessable_entity('差分を表示できない版です.') return head :not_found if (from && !from_rev) || !to_rev
from_body = from_rev && render_group_version(locale, from_rev)
to_body = render_group_version(locale, to_rev)
resource = { 'id' => tag_group.id,
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
'deprecated_at' => nil }
else
return head :not_found
end end
resource = WikiPageRepr.base(page)
end end
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) from_body ||= from_rev&.body
diff_json = diffs.map { |change| to_body ||= to_rev.body
case change.action diff_json = wiki_diff(from_body, to_body)
when ?=
{ type: 'context', content: change.old_element }
when ?!
[{ type: 'removed', content: change.old_element },
{ type: 'added', content: change.new_element }]
when ?+
{ type: 'added', content: change.new_element }
when ?-
{ type: 'removed', content: change.old_element }
end
}.flatten.compact
render json: { wiki_page_id: resource.fetch('id'), render json: { wiki_page_id: resource.fetch('id'),
title: resource.fetch('title'), title: resource.fetch('title'),
@@ -109,6 +128,8 @@ class WikiPagesController < ApplicationController
older_revision_id: from_rev&.id, older_revision_id: from_rev&.id,
newer_revision_id: to_rev.id, newer_revision_id: to_rev.id,
diff: diff_json } diff: diff_json }
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
render_unprocessable_entity(error.message)
end end
def create def create
@@ -122,30 +143,37 @@ 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?
page = if title.include?(':') locale = resolve_locale!
ApplicationRecord.transaction do
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
name.script_code = 'Jpan'
name.primary_flg = true
name.auto_generated = false
end
Wiki::Commit.create_legacy_content!( if (name = TagGroupRepr.name_from_title(title))
tag_name:, body:, created_by_user: current_user, message:) parsed = TagGroupMarkdownParser.parse(locale, body:, group: nil)
end group = TagGroupOperation.create!(
else locale, name:, **parsed.except(:compatibility_body), actor: current_user,
tag_name = TagName.find_by(language_code: 'ja', name: title) require_compatibility: true)
return render_unprocessable_entity field: :title unless tag_name&.tag version = current_group_version(group)
return render_group_representation(TagGroupRepr.one(locale, group), version,
TagDescriptionOperation.create!( status: :created)
tag: tag_name.tag, body:, created_by_user: current_user, message:)
end end
render json: WikiPageRepr.base(page), status: :created description =
if title.include?(':')
return render_unprocessable_entity field: :title
else
tag_name = TagName.find_by(language_code: locale.language_code, name: title)
return render_unprocessable_entity field: :title unless tag_name&.tag
TagDescriptionOperation.create!(
locale,
tag: tag_name.tag, body:, created_by_user: current_user, message:)
end
render json: WikiPageRepr.tag_description(locale, description), status: :created
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record render_validation_error e.record
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
render_record_not_unique render_record_not_unique
rescue TagGroupMarkdownParser::InvalidDocument => error
render_unprocessable_entity(error.message)
end end
def update def update
@@ -158,93 +186,177 @@ 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?
page = WikiPage.find(params[:id]) locale = resolve_locale!
group = TagGroup.find_by(id: params[:id])
if (name = TagGroupRepr.name_from_title(title))
return head :not_found unless group
parsed = TagGroupMarkdownParser.parse(locale, body:, group:)
TagGroupOperation.update!(
locale, group:, name:, **parsed.except(:compatibility_body), actor: current_user,
require_compatibility: true)
return head :ok
end
return render_unprocessable_entity field: :title if title.include?(':')
description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
unless description
return render_unprocessable_entity field: :title if group
return head :not_found
end
tag = description.tag
tag_name = tag.tag_name(locale.language_code)
return render_unprocessable_entity field: :title unless tag_name
return render_unprocessable_entity field: :title if tag_name.name.include?(':')
base_revision_id = params[:base_revision_id].presence 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
Wiki::Commit.content!(page:, TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
body:, message:, base_revision_id:)
created_user: current_user,
message:,
base_revision_id:)
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
end end
head :ok head :ok
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique
render_record_not_unique
rescue TagGroupMarkdownParser::InvalidDocument => error
render_unprocessable_entity(error.message)
end 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
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages tag_group = id && TagGroup.find_by(id:)
revisions = WikiRevision.joins(wiki_page: :tag_name) if tag_group
.where(wiki_page_id: pages.select(:id)) return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
.includes(:created_user, wiki_page: { tag_name: :tag }) end
.order(id: :desc).limit(200)
json = revisions.map { |rev| json = []
{ 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(description_changes(TagDescription.where(language_code: 'ja'))) json.concat(tag_group_changes(TagGroupVersion.all))
json.sort_by! { |row| -row[:revision_id] } json.concat(description_changes(locale,
TagDescription.where(language_code: locale.language_code)))
json = json.each_with_index.sort_by do |row, index|
[-row.fetch(:timestamp).to_f, index]
end.map(&:first)
end end
render json: json.first(200) render json: json.first(200)
end end
private private
def render_wiki_page_or_404 page def render_tag_group locale, group
return head :not_found unless page version = if params[:version].present?
group_version(group, params[:version])
rev = find_revision(page) else
return head :not_found unless rev current_group_version(group)
if rev.redirect?
return (
redirect_to wiki_page_by_title_path(title: rev.redirect_page.title),
status: :moved_permanently)
end end
return head :not_found unless version
body = rev.body representation =
revision_id = rev.id if params[:version].present?
pred = page.pred_revision_id(revision_id) TagGroupRepr.historical(locale, version)
succ = page.succ_revision_id(revision_id) else
updated_at = rev.created_at TagGroupRepr.one(locale, group)
end
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:) render_group_representation(representation, version)
end end
def render_wiki_page_by_tag_description description def render_group_version locale, version
representation = TagGroupRepr.historical(locale, version)
TagGroupMarkdownRenderer.render(representation)
end
def render_group_representation representation, version, body: nil, status: :ok
body ||= TagGroupMarkdownRenderer.render(representation)
versions = TagGroupVersion.where(tag_group_id: version.tag_group_id)
pred = versions.find_by(version_no: version.version_no - 1)&.id
succ = versions.find_by(version_no: version.version_no + 1)&.id
updated_at = version.created_at
render json: {
'id' => version.tag_group_id,
'resource_type' => 'tag_group',
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
'deprecated_at' => nil,
'body' => body,
'revision_id' => version.id,
'pred' => pred,
'succ' => succ,
'updated_at' => updated_at }, status:
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
render_unprocessable_entity(error.message)
end
def current_group_version group
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def group_version group, version_id
TagGroupVersion.find_by(tag_group_id: group.id, id: version_id)
end
def wiki_diff from_body, to_body
diffs = Diff::LCS.sdiff(from_body.to_s.lines, to_body.to_s.lines)
diffs.map { |change|
case change.action
when ?=
{ type: 'context', content: change.old_element }
when ?!
[{ type: 'removed', content: change.old_element },
{ type: 'added', content: change.new_element }]
when ?+
{ type: 'added', content: change.new_element }
when ?-
{ type: 'removed', content: change.old_element }
end
}.flatten.compact
end
def tag_group_changes versions
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id)
predecessors = TagGroupVersion.where(tag_group_id: groups.keys,
version_no: rows.map { _1.version_no - 1 })
.pluck(:tag_group_id, :version_no, :id)
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
rows.map do |version|
group = groups.fetch(version.tag_group_id)
user = version.created_by_user
{ revision_id: version.id,
pred: predecessors[[group.id, version.version_no - 1]],
succ: nil,
wiki_page: { id: group.id, title: "#{ TagGroupRepr::PREFIX }#{ version.name }",
deprecated_at: nil },
user: user && { id: user.id, name: user.name },
kind: 'content', message: nil, timestamp: version.created_at }
end
end
def render_wiki_page_by_tag_description locale, description
versions = description_versions(description) 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]) :
@@ -255,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
@@ -264,20 +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(id: descriptions.select(:id))
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
descriptions.select(:tag_id))
end
def description_changes descriptions
versions = TagDescriptionVersion.where(language_code: 'ja',
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?
@@ -286,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] }
@@ -296,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',
@@ -305,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
+2
ファイルの表示
@@ -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 }
+2 -3
ファイルの表示
@@ -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
+2
ファイルの表示
@@ -1,3 +1,5 @@
class TagDescription < ApplicationRecord class TagDescription < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
belongs_to :tag belongs_to :tag
end end
+1 -1
ファイルの表示
@@ -1,3 +1,3 @@
class TagDescriptionVersion < ApplicationRecord class TagDescriptionVersion < ApplicationRecord
; before_create { SharedResourceIdAllocator.assign!(self) }
end end
+27
ファイルの表示
@@ -0,0 +1,27 @@
class TagGroup < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
before_destroy :delete_current_structure!
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
has_many :tag_group_tags, dependent: :delete_all
has_many :tags, through: :tag_group_tags
has_many :tag_group_groups, dependent: :delete_all
has_many :child_tag_groups, through: :tag_group_groups
has_many :placements, -> { order(:position) },
class_name: 'TagGroupPlacement', dependent: :delete_all
has_many :versions, -> { order(:version_no) },
class_name: 'TagGroupVersion'
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
private
def delete_current_structure!
placements.reorder(position: :desc).each(&:delete)
tag_group_tags.delete_all
tag_group_groups.delete_all
end
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class TagGroupGroup < ApplicationRecord
self.primary_key = [:tag_group_id, :child_tag_group_id]
belongs_to :tag_group
belongs_to :child_tag_group, class_name: 'TagGroup'
belongs_to :created_by_user, class_name: 'User', optional: true
end
+9
ファイルの表示
@@ -0,0 +1,9 @@
class TagGroupPlacement < ApplicationRecord
self.primary_key = [:tag_group_id, :position]
belongs_to :tag_group
belongs_to :tag, optional: true
belongs_to :child_tag_group, class_name: 'TagGroup', optional: true
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class TagGroupTag < ApplicationRecord
self.primary_key = [:tag_group_id, :tag_id]
belongs_to :tag_group
belongs_to :tag
belongs_to :created_by_user, class_name: 'User', optional: true
end
+9
ファイルの表示
@@ -0,0 +1,9 @@
class TagGroupVersion < ApplicationRecord
include VersionRecord
before_create { SharedResourceIdAllocator.assign!(self) }
belongs_to :tag_group
scope :ordered, -> { order(:version_no) }
end
+7 -13
ファイルの表示
@@ -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, '名前に使用できない文字が含まれてゐます.'
+25 -26
ファイルの表示
@@ -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
+4 -4
ファイルの表示
@@ -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)
-15
ファイルの表示
@@ -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
-58
ファイルの表示
@@ -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
-55
ファイルの表示
@@ -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
-8
ファイルの表示
@@ -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
-8
ファイルの表示
@@ -1,8 +0,0 @@
class WikiVersion < ApplicationRecord
include VersionRecord
belongs_to :wiki_page
validates :title, presence: true
validates :body, presence: true
end
+205
ファイルの表示
@@ -0,0 +1,205 @@
module TagGroupRepr
PREFIX = 'タググループ:'
module_function
def name_from_title title
prefix = [PREFIX, 'tag_group:'].find { title.downcase.start_with?(_1) }
title[prefix.length..] if prefix
end
def many locale, groups
ids = groups.map(&:id)
return [] if ids.empty?
tag_members = TagGroupTag.where(tag_group_id: ids)
.group_by(&:tag_group_id)
group_members = TagGroupGroup.where(tag_group_id: ids)
.group_by(&:tag_group_id)
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
.group_by(&:tag_group_id)
target_ids =
tag_members.values.flatten.map(&:tag_id) +
placements.values.flatten.filter_map(&:tag_id)
child_ids =
group_members.values.flatten.map(&:child_tag_group_id) +
placements.values.flatten.filter_map(&:child_tag_group_id)
tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id)
names =
TagName
.where(tag_id: target_ids.uniq,
language_code: locale.language_code,
primary_flg: true)
.pluck(:tag_id, :name)
.to_h
child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id)
groups.map do |group|
group_json(group,
tag_members.fetch(group.id, []),
group_members.fetch(group.id, []),
placements.fetch(group.id, []), tags, names, child_groups)
end
end
def one locale, group
many(locale, [group]).first
end
def tag_group wiki_page
{ 'id' => wiki_page.id,
'title' => "#{ PREFIX }#{ wiki_page.name }",
'deprecated_at' => nil,
'created_at' => wiki_page.created_at,
'updated_at' => wiki_page.updated_at }
end
def current_version group
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def historical locale, version
members = Array(version.members)
placements = Array(version.placements)
tag_references = members.filter_map { |member| member['tag_id'] } +
collect_ids(placements, 'tag_id')
group_references = members.filter_map { |member| member['tag_group_id'] } +
collect_ids(placements, 'tag_group_id')
tag_versions = historical_tag_versions(tag_references)
group_versions = historical_group_versions(group_references)
tag_version_numbers = members.filter_map do |member|
next unless member['tag_id']
[member.fetch('tag_id'), member.fetch('version_no')]
end.to_h
group_version_numbers = members.filter_map do |member|
next unless member['tag_group_id']
[member.fetch('tag_group_id'), member.fetch('version_no')]
end.to_h
historical_members = members.map do |member|
historical_member(locale, member, tag_versions, group_versions)
end
historical_placements = placements.map do |node|
historical_placement(locale, node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end
{ 'id' => version.tag_group_id,
'name' => version.name,
'description' => version.description,
'version_no' => version.version_no,
'members' => historical_members,
'placements' => historical_placements,
'created_at' => version.created_at,
'updated_at' => version.created_at,
'created_by_user' => user_json(version.created_by_user),
'updated_by_user' => user_json(version.created_by_user) }
end
def group_json group, tag_members, group_members, rows, tags, names, child_groups
tree = placement_tree(rows, tags, names, child_groups)
members = tag_members.sort_by(&:tag_id).map do |member|
{ 'tag' => tag_json(tags.fetch(member.tag_id), names[member.tag_id]) }
end
members.concat(group_members.sort_by(&:child_tag_group_id).map do |member|
{ 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) }
end)
{ 'id' => group.id,
'name' => group.name,
'description' => group.description,
'version_no' => group.version_no,
'members' => members,
'placements' => tree,
'created_at' => group.created_at,
'updated_at' => group.updated_at,
'created_by_user' => user_json(group.created_by_user),
'updated_by_user' => user_json(group.updated_by_user) }
end
def user_json user
UserRepr.base(user) if user
end
def placement_tree rows, tags, names, child_groups
by_parent = rows.group_by(&:parent_position)
build = lambda do |parent|
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
node = if row.tag_id
{ 'tag' => tag_json(tags.fetch(row.tag_id), names[row.tag_id]) }
elsif row.child_tag_group_id
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
else
{ 'section_name' => row.section_name, 'description' => row.description }
end
node.merge('children' => build.call(row.position))
end
end
build.call(nil)
end
def tag_json tag, name
{ 'id' => tag.id, 'name' => name, 'category' => tag.category }
end
def group_json_reference group
{ 'id' => group.id, 'name' => group.name }
end
def collect_ids nodes, key
nodes.flat_map do |node|
[node[key], *collect_ids(Array(node['children']), key)].compact
end
end
def historical_tag_versions ids
TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version|
[version.tag_id, version.version_no]
end
end
def historical_group_versions ids
TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version|
[version.tag_group_id, version.version_no]
end
end
def historical_member locale, member, tag_versions, group_versions
if member['tag_id']
id = member.fetch('tag_id')
version = tag_versions.fetch([id, member.fetch('version_no')])
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
'category' => version.category } }
else
id = member.fetch('tag_group_id')
version = group_versions.fetch([id, member.fetch('version_no')])
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
end
end
def historical_placement locale, node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers
result = if node['tag_id']
id = node.fetch('tag_id')
version_no = tag_version_numbers.fetch(id)
version = tag_versions.fetch([id, version_no])
{ 'tag' => { 'id' => id, 'name' => version_name(locale, version),
'category' => version.category } }
elsif node['tag_group_id']
id = node.fetch('tag_group_id')
version_no = group_version_numbers.fetch(id)
version = group_versions.fetch([id, version_no])
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
else
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
end
result.merge('children' => Array(node['children']).map do |child|
historical_placement(locale, child, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end)
end
def version_name locale, version
version.names[locale.language_code]&.fetch('name')
end
end
+11 -4
ファイルの表示
@@ -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)
+7 -11
ファイルの表示
@@ -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,11 +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 many wiki_pages def tag_group tag_group
wiki_pages.map { |p| base(p) } TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
end end
end end
+12 -15
ファイルの表示
@@ -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
+91
ファイルの表示
@@ -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
+47 -34
ファイルの表示
@@ -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
+178
ファイルの表示
@@ -0,0 +1,178 @@
require 'kramdown'
class TagGroupMarkdownParser
PREFIX = TagGroupRepr::PREFIX
class InvalidDocument < StandardError
;
end
def self.parse(locale, body:, group: nil)
new(body:, group:).parse(locale)
end
def initialize(body:, group:)
@body = body.to_s.gsub(/\r\n?/, "\n")
@group = group
end
def parse locale
parse_body = @body.gsub(/([^\n])\n([\x23]{1,6}[ \t]+\S)/, "\\1\n\n\\2")
root = Kramdown::Document.new(parse_body, input: 'GFM', auto_ids: false).root
placements = []
description_parts = []
headings = []
root.children.each do |node|
case node.type
when :blank
next
when :header
level = node.options.fetch(:level)
name = inline_text(node).strip
invalid!('Section 名は必須です.') if name.blank?
headings.pop while headings.any? && headings.last.fetch(:level) >= level
parent = headings.last&.fetch(:node)
section = { 'section_name' => name, 'description' => nil,
'children' => [] }
(parent ? parent.fetch('children') : placements) << section
headings << { level:, node: section }
when :p
text = inline_markdown(node).strip
next if text.empty?
if headings.any?
section = headings.last.fetch(:node)
section['description'] = [section['description'], text].compact.join("\n\n")
else
description_parts << text
end
when :ul
invalid!('未対応のリスト項目があります.') if node.attr.any?
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
target.concat(node.children.map { parse_list_item(locale, _1) })
else
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
end
end
description = description_parts.presence&.join("\n\n")
{ description:, placements:, members: build_members(placements),
compatibility_body: @body.sub(/\n+\z/, '') }
rescue Kramdown::Error => error
invalid!("Markdown を解析できません:#{ error.message }")
end
private
def parse_list_item locale, item
invalid!('リスト項目ではありません.') unless item.type == :li
invalid!('未対応のリスト項目があります.') if item.attr.any?
nested, own = item.children.partition { |child| child.type == :ul }
invalid!('順序付きリストは使用できません.') if own.any? { _1.type == :ol }
own.reject! { _1.type == :blank }
invalid!('未対応のリスト項目があります.') unless own.all? do |child|
[:p, :text].include?(child.type)
end
invalid!('リスト項目は一つの段落で指定してください.') if own.size != 1
label = own.map { inline_text(_1) }.join(' ').strip
invalid!('空のリスト項目は使用できません.') if label.blank?
node =
if (group_name = TagGroupRepr.name_from_title(label))
group_name = group_name.strip
child_group = TagGroup.find_by(name: group_name)
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
{ 'tag_group_id' => child_group.id }
elsif label.end_with?(':', ':')
section_name = label.sub(/[::]\z/, '').strip
invalid!('Section 名は必須です.') if section_name.blank?
{ 'section_name' => section_name, 'description' => nil }
else
{ 'tag_id' => resolve_tag(locale, label) }
end
node['children'] = nested.flat_map do |list|
invalid!('未対応の入れ子リストです.') unless list.type == :ul
list.children.map { parse_list_item(locale, _1) }
end
node
end
def resolve_tag locale, name
names = TagName.where(language_code: locale.language_code, name:).to_a
tag_ids = names.filter_map(&:tag_id).uniq
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
tag_ids.first
end
def build_members(nodes)
references = []
visit = lambda do |items|
items.each do |node|
references << node.slice('tag_id', 'tag_group_id') if
node.key?('tag_id') || node.key?('tag_group_id')
visit.call(node.fetch('children'))
end
end
visit.call(nodes)
references.uniq
end
def inline_text(node)
if node.attr.any?
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
end
case node.type
when :text
node.value.to_s
when :p, :header
node.children.map { inline_text(_1) }.join
else
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
end
end
def inline_markdown(node)
case node.type
when :text
node.value.to_s
when :codespan
text = node.value.to_s
longest = text.scan(/`+/).map(&:length).max || 0
delimiter = '`' * (longest + 1)
padded = text.start_with?('`', ' ') || text.end_with?('`', ' ')
content = padded ? " #{ text } " : text
"#{ delimiter }#{ content }#{ delimiter }"
when :softbreak
"\n"
when :br
" \n"
when :p
node.children.map { inline_markdown(_1) }.join
when :a
href = node.attr.fetch('href')
label = node.children.map { inline_markdown(_1) }.join
title = node.attr['title']&.gsub('"', '\\"')
suffix = title ? " \"#{ title }\"" : ''
"[#{ label }](#{ href }#{ suffix })"
when :em
"*#{ node.children.map { inline_markdown(_1) }.join }*"
when :strong
"**#{ node.children.map { inline_markdown(_1) }.join }**"
else
invalid!("未対応の説明文構文 #{ node.type.inspect } です.")
end
end
def invalid! message
raise InvalidDocument, message
end
end
+123
ファイルの表示
@@ -0,0 +1,123 @@
class TagGroupMarkdownRenderer
class UnsupportedStructure < StandardError
;
end
def self.render group
new(group).render
end
def initialize group
@group = group
end
def render
placements = @group.fetch('placements')
validate_membership!(placements)
blocks = []
if @group['description'].present?
blocks << clean_text(@group['description'])
end
first_section = placements.index { _1.key?('section_name') }
if first_section
if placements.drop(first_section).any? { !_1.key?('section_name') }
raise UnsupportedStructure,
'見出し Section の後に最上位 Placement は配置できません.'
end
if first_section.positive?
blocks << render_list(placements.take(first_section), 0)
end
blocks.concat(placements.drop(first_section).map { render_heading(_1, 2) })
elsif placements.any?
blocks << render_list(placements, 0)
end
blocks.compact.reject(&:empty?).join("\n").sub(/\n+\z/, '')
end
private
def validate_membership! placements
placed = placement_members(placements).uniq.sort
members = @group.fetch('members').map { |member|
if member['tag']
['tag', member.fetch('tag').fetch('id')]
else
['tag_group', member.fetch('tag_group').fetch('id')]
end
}.uniq.sort
missing = members - placed
if missing.any?
raise UnsupportedStructure, '掲載位置のない membership は Markdown にできません.'
end
end
def placement_members nodes
nodes.flat_map do |node|
own = if node['tag']
[['tag', node.fetch('tag').fetch('id')]]
elsif node['tag_group']
[['tag_group', node.fetch('tag_group').fetch('id')]]
else
[]
end
own + placement_members(node.fetch('children'))
end
end
def render_heading section, level
header = "#{ '#' * level } #{ clean_text(section.fetch('section_name')) }"
blocks = [header]
blocks << clean_text(section['description']) if section['description'].present?
children = section.fetch('children')
section_indexes = children.each_index.select { |index| children[index].key?('section_name') }
if section_indexes.any?
first_section = section_indexes.first
if children[first_section..].any? { !_1.key?('section_name') }
raise UnsupportedStructure,
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
end
list_nodes = children[0...first_section]
blocks << render_list(list_nodes, 0) if list_nodes.any?
blocks.concat(children[first_section..].map { render_heading(_1, level + 1) })
elsif children.any?
blocks << render_list(children, 0)
end
blocks.join("\n")
end
def render_list nodes, depth
nodes.map do |node|
indent = ' ' * depth
if node.key?('section_name')
raise UnsupportedStructure,
'リスト内 Section の説明文は Markdown にできません.' if node['description'].present?
children = node.fetch('children')
head = "#{ indent }- #{ clean_text(node.fetch('section_name')) }:"
tail = render_list(children, depth + 1) if children.any?
[head, tail].compact.join("\n")
else
label = if node['tag']
node.fetch('tag').fetch('name')
else
"#{ TagGroupRepr::PREFIX }#{ node.fetch('tag_group').fetch('name') }"
end
raise UnsupportedStructure, '名称のない Placement は Markdown にできません.' if label.blank?
head = "#{ indent }- #{ clean_text(label) }"
children = node.fetch('children')
tail = render_list(children, depth + 1) if children.any?
[head, tail].compact.join("\n")
end
end.join("\n")
end
def clean_text value
value.to_s.gsub(/\r\n?/, "\n").lines.map(&:rstrip).join("\n").strip
end
end
+362
ファイルの表示
@@ -0,0 +1,362 @@
class TagGroupOperation
MAX_PLACEMENT_DEPTH = 5
def self.create!(locale, name:, description:, members:, placements:, actor:,
require_compatibility: false)
ApplicationRecord.transaction do
group = TagGroup.new(version_no: 1, created_by_user: actor,
updated_by_user: actor)
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
group.assign_attributes(name: attributes.fetch(:name),
description: attributes.fetch(:description))
group.save!
persist_structure!(group:, attributes:, actor:)
validate_compatibility!(locale, group) if require_compatibility
persist_version!(group:, attributes:, actor:, event_type: 'create')
group
end
end
def self.update!(locale, group:, name:, description:, members:, placements:, actor:,
require_compatibility: false)
ApplicationRecord.transaction do
group.lock!
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
if unchanged?(group, attributes)
validate_compatibility!(locale, group) if require_compatibility
next group
end
group.update!(name: attributes.fetch(:name),
description: attributes.fetch(:description),
version_no: group.version_no + 1,
updated_by_user: actor)
persist_structure!(group:, attributes:, actor:)
validate_compatibility!(locale, group) if require_compatibility
persist_version!(group:, attributes:, actor:, event_type: 'update')
group
end
end
def self.validate_compatibility! locale, group
TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group))
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
invalid!(group, :base, error.message)
end
private_class_method :validate_compatibility!
def self.prepare!(locale:, group:, name:, description:, members:, placements:)
record = group
normalized_name = name.to_s.strip
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
if normalized_name.length > 255
invalid!(record, :name, 'グループ名は 255 文字以内で入力してください.')
end
invalid!(record, :description, '説明文は文字列で入力してください.') unless
description.nil? || description.is_a?(String)
record.name = normalized_name
record.description = description
unless record.valid?
raise ActiveRecord::RecordInvalid, record
end
member_rows = normalize_members(record, members)
placement_tree = normalize_placements(record, placements)
validate_references!(locale, record, member_rows, placement_tree)
validate_containment!(record, member_rows)
{ name: normalized_name, description:, members: member_rows,
placements: placement_tree }
end
private_class_method :prepare!
def self.unchanged?(group, attributes)
unless (group.name_in_database == attributes.fetch(:name) &&
group.description_in_database == attributes.fetch(:description))
return false
end
members = TagGroupTag.where(tag_group_id: group.id).pluck(:tag_id)
.map { { 'tag_id' => _1 } }
members.concat(TagGroupGroup.where(tag_group_id: group.id)
.pluck(:child_tag_group_id).map { { 'tag_group_id' => _1 } })
requested_members = attributes.fetch(:members).map do |member|
member.except('version_no')
end
return false unless members.to_set == requested_members.to_set
rows = TagGroupPlacement.where(tag_group_id: group.id).order(:position)
.group_by(&:parent_position)
build = lambda do |parent_position|
rows.fetch(parent_position, []).map do |row|
node = if row.tag_id
{ 'tag_id' => row.tag_id }
elsif row.child_tag_group_id
{ 'tag_group_id' => row.child_tag_group_id }
else
{ 'section_name' => row.section_name, 'description' => row.description }
end
node.merge('children' => build.call(row.position))
end
end
build.call(nil) == comparable_placements(attributes.fetch(:placements))
end
private_class_method :unchanged?
def self.comparable_placements(nodes)
nodes.map do |node|
result = node.dup
result['description'] = node['description'] if node.key?('section_name')
result['children'] = comparable_placements(node.fetch('children'))
result
end
end
private_class_method :comparable_placements
def self.normalize_members(record, members)
invalid!(record, :members, 'members は配列で指定してください.') unless members.is_a?(Array)
result = members.map do |member|
member = stringify_keys(member)
unless member.is_a?(Hash) && member.keys.one? &&
(member.key?('tag_id') || member.key?('tag_group_id'))
invalid!(record, :members, '各 member は tag_id または tag_group_id を一つ指定してください.')
end
key = member.keys.first
id = integer_id(record, member.fetch(key), :members)
{ key => id }
end
invalid!(record, :members, 'membership に重複があります.') unless result.uniq == result
result
end
private_class_method :normalize_members
def self.normalize_placements(record, placements)
invalid!(record, :placements, 'placements は配列で指定してください.') unless
placements.is_a?(Array)
normalize_placement_nodes(record, placements, 1)
end
private_class_method :normalize_placements
def self.normalize_placement_nodes(record, nodes, depth)
if depth > MAX_PLACEMENT_DEPTH && nodes.any?
invalid!(record, :placements, '配置の深さは 5 階層までです.')
end
nodes.map do |raw_node|
node = stringify_keys(raw_node)
unless node.is_a?(Hash) && node.key?('children')
invalid!(record, :placements, '各 Placement に children 配列が必要です.')
end
children = node.delete('children')
unless children.is_a?(Array)
invalid!(record, :placements, 'children は配列で指定してください.')
end
kinds = ['tag_id', 'tag_group_id', 'section_name'].select { node.key?(_1) }
invalid!(record, :placements, 'Placement の種類を一つ指定してください.') unless
kinds.one?
kind = kinds.first
allowed = [kind, 'description']
invalid!(record, :placements, '不明な Placement 項目があります.') unless
(node.keys - allowed).empty?
case kind
when 'tag_id', 'tag_group_id'
node[kind] = integer_id(record, node.fetch(kind), :placements)
if node['description'].present?
invalid!(record, :placements, 'Tag / Tag Group Placement に説明文は指定できません.')
end
node.delete('description')
when 'section_name'
section_name = node.fetch(kind)
unless section_name.is_a?(String) && section_name.strip.present? &&
section_name.length <= 255
invalid!(record, :placements, 'Section 名は 1〜255 文字で入力してください.')
end
node[kind] = section_name.strip
unless node['description'].nil? || node['description'].is_a?(String)
invalid!(record, :placements, 'Section の説明文は文字列で入力してください.')
end
end
node['children'] = normalize_placement_nodes(record, children, depth + 1)
node
end
end
private_class_method :normalize_placement_nodes
def self.validate_references!(locale, record, members, placements)
membership = members.to_set
placed = placement_references(placements).uniq
missing = placed - membership.to_a
invalid!(record, :placements, 'Placement の対象は membership に含めてください.') if
missing.any?
tag_ids = members.filter_map { _1['tag_id'] }
group_ids = members.filter_map { _1['tag_group_id'] }
tags = Tag.where(id: tag_ids).index_by(&:id)
groups = TagGroup.where(id: group_ids).index_by(&:id)
invalid!(record, :members, '存在しない Tag が含まれています.') if
tags.size != tag_ids.uniq.size
invalid!(record, :members, '存在しない Tag Group が含まれています.') if
groups.size != group_ids.uniq.size
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
.pluck(:tag_group_id, :version_no).to_set
tag_names = TagName.where(
tag_id: tag_ids, language_code: locale.language_code, primary_flg: true)
.pluck(:tag_id).uniq
members.each do |member|
if member.key?('tag_id')
id = member.fetch('tag_id')
invalid!(record, :members, :invalid) unless
tag_names.include?(id)
version_no = tags.fetch(id).version_no
unless tag_versions.include?([id, version_no])
invalid!(record, :members, "Tag ##{ id } の Version がありません.")
end
member['version_no'] = version_no
else
id = member.fetch('tag_group_id')
version_no = groups.fetch(id).version_no
unless group_versions.include?([id, version_no])
invalid!(record, :members, "Tag Group ##{ id } の Version がありません.")
end
member['version_no'] = version_no
end
end
end
private_class_method :validate_references!
def self.validate_containment!(record, members)
target_id = record.id
return unless target_id
children = members.filter_map { _1['tag_group_id'] }
edges = TagGroupGroup.pluck(:tag_group_id, :child_tag_group_id)
.group_by(&:first).transform_values { _1.map(&:last) }
edges[target_id] = children
visiting = Set.new
visited = Set.new
visit = lambda do |group_id|
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
group_id == target_id && visiting.include?(group_id)
return if visited.include?(group_id)
visiting.add(group_id)
edges.fetch(group_id, []).each do |child_id|
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
child_id == target_id
visit.call(child_id)
end
visiting.delete(group_id)
visited.add(group_id)
end
children.each { visit.call(_1) }
end
private_class_method :validate_containment!
def self.persist_structure!(group:, attributes:, actor:)
tag_members = TagGroupTag.where(tag_group_id: group.id).index_by(&:tag_id)
group_members = TagGroupGroup.where(tag_group_id: group.id).index_by(&:child_tag_group_id)
attributes.fetch(:members).each do |member|
if member.key?('tag_id')
next if tag_members.delete(member.fetch('tag_id'))
TagGroupTag.create!(tag_group_id: group.id, tag_id: member.fetch('tag_id'),
created_by_user: actor)
else
next if group_members.delete(member.fetch('tag_group_id'))
TagGroupGroup.create!(tag_group_id: group.id,
child_tag_group_id: member.fetch('tag_group_id'),
created_by_user: actor)
end
end
existing = TagGroupPlacement.where(tag_group_id: group.id).index_by(&:position)
persist_placements!(group:, nodes: attributes.fetch(:placements), actor:, existing:)
existing.values.sort_by(&:position).reverse_each(&:delete)
tag_members.each_value(&:delete)
group_members.each_value(&:delete)
end
private_class_method :persist_structure!
def self.persist_placements!(group:, nodes:, actor:, existing:, parent_position: nil,
next_position: [0])
nodes.each do |node|
next_position[0] += 1
position = next_position[0]
row = existing.delete(position) || TagGroupPlacement.new(
tag_group_id: group.id, position:, created_by_user: actor)
row.assign_attributes(
parent_position:,
section_name: node['section_name'], description: node['description'],
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'])
if row.new_record? || row.changed?
row.updated_by_user = actor
row.save!
end
persist_placements!(group:, nodes: node.fetch('children'), actor:, existing:,
parent_position: position, next_position:)
end
end
private_class_method :persist_placements!
def self.persist_version!(group:, attributes:, actor:, event_type:)
TagGroupVersion.create!(tag_group: group, version_no: group.version_no,
event_type:, name: group.name,
description: group.description,
members: attributes.fetch(:members),
placements: attributes.fetch(:placements),
created_by_user: actor)
end
private_class_method :persist_version!
def self.placement_references(nodes)
nodes.flat_map do |node|
own = if node.key?('tag_id')
[{ 'tag_id' => node.fetch('tag_id') }]
elsif node.key?('tag_group_id')
[{ 'tag_group_id' => node.fetch('tag_group_id') }]
else
[]
end
own + placement_references(node.fetch('children'))
end
end
private_class_method :placement_references
def self.stringify_keys(value)
case value
when ActionController::Parameters
value.to_unsafe_h.deep_stringify_keys
when Hash
value.deep_stringify_keys
else
value
end
end
private_class_method :stringify_keys
def self.integer_id(record, value, attribute)
integer = if value.is_a?(Integer)
value
elsif value.is_a?(String) && value.match?(/\A[0-9]+\z/)
value.to_i
end
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer.positive?
integer
end
private_class_method :integer_id
def self.invalid!(record, attribute, message)
record.errors.add(attribute, message)
raise ActiveRecord::RecordInvalid, record
end
private_class_method :invalid!
end
-160
ファイルの表示
@@ -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
-33
ファイルの表示
@@ -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
-21
ファイルの表示
@@ -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
+2
ファイルの表示
@@ -1,4 +1,6 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :tag_groups, only: [:index, :show, :create, :update]
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
scope 'tags/:parent_id/children', controller: :tag_children do scope 'tags/:parent_id/children', controller: :tag_children do
+149
ファイルの表示
@@ -0,0 +1,149 @@
class CreateTagGroups < ActiveRecord::Migration[8.0]
def change
# 翻訳系は上流の Translation リソースが未完のため保留
create_table :tag_groups do |t|
t.integer :version_no, null: false
t.string :name, limit: 255, null: false
t.text :description
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.check_constraint 'version_no > 0', name: 'chk_tag_groups_version_no_positive'
t.index :name, unique: true
end
create_table :tag_group_tags, primary_key: [:tag_group_id, :tag_id] do |t|
t.references :tag_group, null: false, foreign_key: true, index: false
t.references :tag, null: false, foreign_key: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.datetime :created_at, null: false
end
create_table :tag_group_groups, primary_key: [:tag_group_id, :child_tag_group_id] do |t|
t.references :tag_group, null: false, foreign_key: true, index: false
t.references :child_tag_group,
null: false, foreign_key: { to_table: :tag_groups }
t.references :created_by_user, foreign_key: { to_table: :users }
t.datetime :created_at, null: false
t.check_constraint 'tag_group_id <> child_tag_group_id',
name: 'chk_tag_group_groups_tag_group_id_not_child_tag_group_id'
end
create_table :tag_group_placements, primary_key: [:tag_group_id, :position] do |t|
t.references :tag_group, null: false, foreign_key: true, index: false
t.integer :position, null: false
t.integer :parent_position
t.string :section_name, limit: 255
t.text :description
t.references :tag
t.references :child_tag_group
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.foreign_key :tag_group_placements,
column: [:tag_group_id, :parent_position],
primary_key: [:tag_group_id, :position]
t.foreign_key :tag_group_tags,
column: [:tag_group_id, :tag_id],
primary_key: [:tag_group_id, :tag_id]
t.foreign_key :tag_group_groups,
column: [:tag_group_id, :child_tag_group_id],
primary_key: [:tag_group_id, :child_tag_group_id]
t.check_constraint 'position > parent_position',
name: 'chk_tag_group_placements_position_gt_parent_position'
t.check_constraint ('((section_name IS NOT NULL AND tag_id IS NULL AND child_tag_group_id IS NULL) OR ' +
'(section_name IS NULL AND tag_id IS NOT NULL AND child_tag_group_id IS NULL) OR ' +
'(section_name IS NULL AND tag_id IS NULL AND child_tag_group_id IS NOT NULL)) AND ' +
'(section_name IS NOT NULL OR description IS NULL)'),
name: 'chk_tag_group_placements_references_exclusive'
end
create_table :tag_group_versions do |t|
t.references :tag_group, null: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :name, limit: 255, null: false
t.text :description
t.json :members, null: false
t.json :placements, null: false
t.references :created_by_user
t.datetime :created_at, null: false
t.check_constraint 'version_no > 0',
name: 'chk_tag_group_versions_version_no_positive'
t.index [:tag_group_id, :version_no], unique: true
add_members_constraint!(t)
add_placements_constraint!(t)
end
end
private
def add_members_constraint! t
schema = { type: 'array',
uniqueItems: true,
items: member_schema }
quoted_schema = connection.quote(JSON.generate(schema))
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, members)",
name: 'chk_tag_group_versions_members_schema'
end
def member_schema
{ oneOf: [tag_member_schema, group_member_schema] }
end
def tag_member_schema
{ type: 'object',
required: ['tag_id', 'version_no'],
properties: { tag_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 } },
additionalProperties: false }
end
def group_member_schema
{ type: 'object',
required: ['tag_group_id', 'version_no'],
properties: { tag_group_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 } },
additionalProperties: false }
end
def add_placements_constraint! t
schema = { type: 'array', items: placement_schema(5) }
quoted_schema = connection.quote(JSON.generate(schema))
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, placements)",
name: 'chk_tag_group_versions_placements_schema'
end
def placement_schema rest
{ type: 'object',
required: ['children'],
properties: { section_name: { type: 'string' },
description: { type: ['string', 'null'] },
tag_id: { type: 'integer', minimum: 1 },
tag_group_id: { type: 'integer', minimum: 1 },
children: if rest > 1
{ type: 'array', items: placement_schema(rest - 1) }
else
{ type: 'array', maxItems: 0 }
end },
oneOf: [{ required: ['section_name'] },
{ required: ['tag_id'] },
{ required: ['tag_group_id'] }],
dependencies: { description: ['section_name'] },
additionalProperties: false }
end
end
+518
ファイルの表示
@@ -0,0 +1,518 @@
class MigrateTagGroupsFromWikiPages < ActiveRecord::Migration[8.0]
PREFIX = 'タググループ:'
def up
require 'json'
require 'time'
require 'kramdown'
require 'kramdown-parser-gfm'
assert_empty_destination!
pages = connection.select_all(<<~SQL).to_a
SELECT
wp.id
, wp.created_user_id
, wp.updated_user_id
, wp.created_at
, wp.updated_at
, wp.discarded_at
, tn.name
FROM
wiki_pages wp
INNER JOIN
tag_names tn
ON
tn.id = wp.tag_name_id
WHERE
tn.language_code = 'ja'
AND tn.name LIKE #{ quote("#{ PREFIX }%") }
ORDER BY
wp.id
SQL
return if pages.empty?
pages.each do |page|
if page['discarded_at']
say "Wiki ##{ page.fetch('id') }: 破棄済みですが,Revision の移行を継続します."
end
end
tag_ids = load_tag_ids
page_ids = pages.map { |page| page.fetch('id').to_i }
revisions = connection.select_all(<<~SQL).to_a
SELECT
wr.id
, wr.wiki_page_id
, wr.kind
, wr.redirect_page_id
, wr.created_user_id
, wr.created_at
FROM
wiki_revisions wr
WHERE
wr.wiki_page_id IN (#{ page_ids.join(', ') })
ORDER BY
wr.wiki_page_id
, wr.id
SQL
revisions_by_page = revisions.group_by { |row| row.fetch('wiki_page_id').to_i }
pages = pages.select do |page|
page_id = page.fetch('id').to_i
if revisions_by_page.fetch(page_id, []).empty?
# tag_groups.version_no > 0 のため,Revision 0 件の Wiki は作成しない.
say "Wiki ##{ page_id }: Revision が 0 件のため移行対象から除外します."
false
else
true
end
end
return if pages.empty?
group_ids = pages.to_h { |page| [page.fetch('name'), page.fetch('id').to_i] }
revision_history = pages.to_h do |page|
page_id = page.fetch('id').to_i
history = revisions_by_page.fetch(page_id).each_with_index.map do |revision, index|
{ 'id' => revision.fetch('id').to_i,
'version_no' => index + 1,
'created_at' => revision.fetch('created_at') }
end
[page_id, history]
end
tag_history = connection.select_all(<<~SQL).to_a.group_by { |row| row.fetch('tag_id').to_i }
SELECT
tag_id
, version_no
, created_at
FROM
tag_versions
ORDER BY
tag_id
, created_at
, version_no
SQL
prepared = { }
pages.each do |page|
page_id = page.fetch('id').to_i
history = revisions_by_page.fetch(page_id)
prepared[page_id] = history.each_with_index.map do |revision, index|
context = "Wiki ##{ page_id } / Revision ##{ revision.fetch('id') }"
body = revision_body(revision.fetch('id').to_i)
if revision.fetch('kind').to_i == 1
destination = revision['redirect_page_id']
description = "旧 Wiki のリダイレクト先: Wiki ##{ destination }"
placements = []
members = []
say "#{ context }: リダイレクトを説明文として移行します."
else
begin
description, placements = parse_wiki(body, tag_ids, group_ids, context)
validate_tree!(placements, context)
members = build_members(placements, revision.fetch('created_at'),
tag_history, revision_history, context)
rescue StandardError => error
# 解析できない版も捨てず,当時の Markdown を説明文として保存する.
say "#{ context }: 構造化を保留し,本文を保存します (#{ error.message })."
description = body
placements = []
members = []
end
end
{ revision:, version_no: index + 1, description:, members:, placements:, body: }
end
end
# 自己包含は禁止されてゐるため,当該版は原文の保存を優先する.
prepared.each do |group_id, versions|
current = versions.last
next unless current.fetch(:members).any? { |member| member['tag_group_id'] == group_id }
say "Tag Group ##{ group_id }: 直接の自己参照を検出したため,現行構造化を保留します."
current[:description] = current.fetch(:body)
current[:members] = []
current[:placements] = []
end
begin
validate_current_groups!(prepared)
rescue StandardError => error
say "Tag Group の包含関係に問題がありますが,移行を継続します (#{ error.message })."
end
# 移行先の書込みはすべて検証の後.DDL は含めない.
connection.transaction do
pages.each do |page|
page_id = page.fetch('id').to_i
current = prepared.fetch(page_id).last
insert_row('tag_groups', {
id: page_id,
# page.version_no ではなく,この Wiki に紐付く Revision の総数.
version_no: revisions_by_page.fetch(page_id).size,
name: page.fetch('name').delete_prefix(PREFIX),
description: current.fetch(:description),
created_by_user_id: page.fetch('created_user_id'),
updated_by_user_id: page.fetch('updated_user_id'),
created_at: page.fetch('created_at'),
updated_at: page.fetch('updated_at') })
end
pages.each do |page|
page_id = page.fetch('id').to_i
name = page.fetch('name').delete_prefix(PREFIX)
prepared.fetch(page_id).each do |item|
revision = item.fetch(:revision)
insert_row('tag_group_versions', {
id: revision.fetch('id'),
tag_group_id: page_id,
version_no: item.fetch(:version_no),
event_type: item.fetch(:version_no) == 1 ? 'create' : 'update',
name:,
description: item.fetch(:description),
members: JSON.generate(item.fetch(:members)),
placements: JSON.generate(item.fetch(:placements)),
created_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at') })
end
end
pages.each do |page|
page_id = page.fetch('id').to_i
current = prepared.fetch(page_id).last
revision = current.fetch(:revision)
current.fetch(:members).each do |member|
if member.key?('tag_id')
insert_row('tag_group_tags', {
tag_group_id: page_id,
tag_id: member.fetch('tag_id'),
created_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at') })
else
insert_row('tag_group_groups', {
tag_group_id: page_id,
child_tag_group_id: member.fetch('tag_group_id'),
created_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at') })
end
end
end
pages.each do |page|
page_id = page.fetch('id').to_i
current = prepared.fetch(page_id).last
revision = current.fetch(:revision)
position = 0
append = -> nodes, parent_position do
nodes.each do |node|
position += 1
own_position = position
insert_row('tag_group_placements', {
tag_group_id: page_id,
position: own_position,
parent_position:,
section_name: node['section_name'],
description: node['description'],
tag_id: node['tag_id'],
child_tag_group_id: node['tag_group_id'],
created_by_user_id: revision.fetch('created_user_id'),
updated_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at'),
updated_at: revision.fetch('created_at') })
append.(node.fetch('children'), own_position)
end
end
append.(current.fetch(:placements), nil)
end
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def assert_empty_destination!
['tag_groups', 'tag_group_versions', 'tag_group_tags',
'tag_group_groups', 'tag_group_placements'].each do |table|
count = connection.select_value("SELECT COUNT(*) FROM #{ table }").to_i
raise "#{ table } が空ではありません." if count.positive?
end
end
# 解決は tag_names.name -> tag_names.tag_id のみ.
# tags.tag_name_id は読まない.
def load_tag_ids
rows = connection.select_all(<<~SQL).to_a
SELECT
name
, tag_id
FROM
tag_names
WHERE
language_code = 'ja'
AND tag_id IS NOT NULL
SQL
rows.each_with_object({ }) do |row, by_name|
name = row.fetch('name')
id = row.fetch('tag_id').to_i
by_name[name] ||= []
by_name[name] << id unless by_name[name].include?(id)
end
end
def revision_body revision_id
connection.select_rows(<<~SQL).flatten.join("\n")
SELECT
wl.body
FROM
wiki_revision_lines wrl
INNER JOIN wiki_lines wl ON wl.id = wrl.wiki_line_id
WHERE
wrl.wiki_revision_id = #{ Integer(revision_id) }
ORDER BY
wrl.position
SQL
end
def parse_wiki markdown, tag_ids, group_ids, context
root = Kramdown::Document.new(markdown, input: 'GFM').root
placements = []
description_parts = []
headings = []
root.children.each_with_index do |node, index|
case node.type
when :blank
next
when :header
level = node.options.fetch(:level)
name = inline_text(node).strip
raise "#{ context }: 名前のない見出しがあります." if name.empty?
headings.pop while headings.any? && headings.last.first >= level
parent = headings.last&.last
section = { 'section_name' => name, 'description' => nil, 'children' => [] }
(parent ? parent.fetch('children') : placements) << section
headings << [level, section]
when :p
text = inline_markdown(node).strip
next if text.empty?
if headings.any?
heading = headings.last.last
heading['description'] = [heading['description'], text].compact.join("\n\n")
else
description_parts << text
end
when :ul, :ol
if headings.any? && see_also_heading?(headings.last.last.fetch('section_name'))
heading = headings.last.last
raw = original_block(markdown, root.children, index)
heading['description'] = [heading['description'], raw].compact.join("\n\n")
else
target = headings.any? ? headings.last.last.fetch('children') : placements
target.concat(node.children.map { |item| parse_list_item(item, tag_ids, group_ids, context) })
end
else
raise "#{ context }: 未対応の Markdown 構文 #{ node.type.inspect }"
end
end
[description_parts.empty? ? nil : description_parts.join("\n\n"), placements]
end
def see_also_heading? name
['see also', '関連項目'].include?(name.strip.downcase.sub(/[::]\z/, ''))
end
def original_block markdown, siblings, index
current = siblings.fetch(index)
start_line = current.options[:location]
raise 'Markdown block の位置が不明です.' unless start_line
following = siblings[(index + 1)..].find do |node|
node.options[:location] && node.options[:location] > start_line
end
end_line = following ? following.options.fetch(:location) - 1 : markdown.lines.length
markdown.lines[(start_line - 1)...end_line].join.strip
end
def parse_list_item item, tag_ids, group_ids, context
raise "#{ context }: リスト項目ではありません." if item.type != :li
nested, own = item.children.partition { |child| [:ul, :ol].include?(child.type) }
own.reject! { |child| child.type == :blank }
unless own.all? { |child| [:p, :text, :a, :codespan].include?(child.type) }
raise "#{ context }: 未対応のリスト項目があります."
end
label = own.map { |child| inline_text(child) }.join(' ').strip
raise "#{ context }: 空のリスト項目があります." if label.empty?
node =
if group_ids.key?(label)
{ 'tag_group_id' => group_ids.fetch(label) }
elsif tag_ids.key?(label)
ids = tag_ids.fetch(label)
unless ids.one?
raise "#{ context }: タグ名 #{ label.inspect } が複数の ID を指します:#{ ids.inspect }"
end
{ 'tag_id' => ids.first }
elsif label.end_with?(':', ':')
name = label.sub(/[::]\z/, '')
raise "#{ context }: 空の区分名があります." if name.empty?
if see_also_heading?(name) && nested.any?
raise "#{ context }: リスト内の See also は説明文への保管処理が未実装です."
end
{ 'section_name' => name, 'description' => nil }
else
raise "#{ context }: 解決不能なリスト項目 #{ label.inspect }"
end
node['children'] = nested.flat_map do |list|
list.children.map { |child| parse_list_item(child, tag_ids, group_ids, context) }
end
node
end
def inline_text node
case node.type
when :text, :codespan
node.value.to_s
when :softbreak, :br
' '
when :a, :p, :header, :em, :strong, :span, :li
node.children.map { |child| inline_text(child) }.join
else
raise "未対応のインライン構文 #{ node.type.inspect }"
end
end
def inline_markdown node
case node.type
when :text
node.value.to_s
when :codespan
"`#{ node.value }`"
when :softbreak
"\n"
when :br
" \n"
when :p
node.children.map { |child| inline_markdown(child) }.join
when :a
href = node.attr.fetch('href')
"[#{ node.children.map { |child| inline_markdown(child) }.join }](#{ href })"
when :em
"*#{ node.children.map { |child| inline_markdown(child) }.join }*"
when :strong
"**#{ node.children.map { |child| inline_markdown(child) }.join }**"
else
raise "未対応のインライン構文 #{ node.type.inspect }"
end
end
def validate_tree! nodes, context, depth = 1
raise "#{ context }: 配置の深さが 5 を超えてゐます." if depth > 5 && nodes.any?
nodes.each do |node|
name = node['section_name']
if name && name.length > 255
raise "#{ context }: 区分名が 255 文字を超えてゐます."
end
validate_tree!(node.fetch('children'), context, depth + 1)
end
end
def build_members nodes, date, tag_history, group_history, context
ids_of_tags = []
ids_of_groups = []
visit = -> items do
items.each do |node|
ids_of_tags << node['tag_id'] if node.key?('tag_id')
ids_of_groups << node['tag_group_id'] if node.key?('tag_group_id')
visit.(node.fetch('children'))
end
end
visit.(nodes)
tags = ids_of_tags.uniq.map do |id|
{ 'tag_id' => id,
'version_no' => version_at(tag_history[id], date, context, "Tag ##{ id }") }
end
groups = ids_of_groups.uniq.map do |id|
{ 'tag_group_id' => id,
'version_no' => version_at(group_history[id], date, context, "TagGroup ##{ id }") }
end
tags + groups
end
def version_at history, date, context, target
raise "#{ context }: #{ target } の履歴が存在しません." unless history&.any?
timestamp = Time.parse(date.to_s)
candidates = history.select do |row|
Time.parse(row.fetch('created_at').to_s) <= timestamp
end
if candidates.empty?
raise "#{ context }: #{ target } は #{ date } 時点で存在しません."
end
candidates.max_by { |row|
[Time.parse(row.fetch('created_at').to_s), row.fetch('version_no').to_i]
}.fetch('version_no').to_i
end
def validate_current_groups! prepared
edges = { }
prepared.each do |group_id, versions|
edges[group_id] = versions.last.fetch(:members)
.filter_map { |member| member['tag_group_id'] }
end
visiting = { }
visited = { }
visit = -> group_id do
raise "Tag Group の循環参照を検出しました:#{ group_id }" if visiting[group_id]
return if visited[group_id]
visiting[group_id] = true
edges.fetch(group_id, []).each { |child_id| visit.(child_id) }
visiting.delete(group_id)
visited[group_id] = true
end
edges.keys.each { |group_id| visit.(group_id) }
end
def quote(value) = connection.quote(value)
def insert_row table, values
columns = values.keys.join(', ')
payload = values.values.map { |value| quote(value) }.join(', ')
execute "INSERT INTO #{ table } (#{ columns }) VALUES (#{ payload })"
end
end
+9
ファイルの表示
@@ -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
+11
ファイルの表示
@@ -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
+15
ファイルの表示
@@ -0,0 +1,15 @@
class DeleteNameForWikiFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
tag_id IS NULL
SQL
change_column_null :tag_names, :tag_id, false
end
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
生成ファイル
+90 -89
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| 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
@@ -483,6 +483,80 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive"
end end
create_table "tag_group_groups", primary_key: ["tag_group_id", "child_tag_group_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.bigint "child_tag_group_id", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["child_tag_group_id"], name: "index_tag_group_groups_on_child_tag_group_id"
t.index ["created_by_user_id"], name: "index_tag_group_groups_on_created_by_user_id"
t.check_constraint "`tag_group_id` <> `child_tag_group_id`", name: "chk_tag_group_groups_tag_group_id_not_child_tag_group_id"
end
create_table "tag_group_placements", primary_key: ["tag_group_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.integer "position", null: false
t.integer "parent_position"
t.string "section_name"
t.text "description"
t.bigint "tag_id"
t.bigint "child_tag_group_id"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["child_tag_group_id"], name: "index_tag_group_placements_on_child_tag_group_id"
t.index ["created_by_user_id"], name: "index_tag_group_placements_on_created_by_user_id"
t.index ["tag_group_id", "child_tag_group_id"], name: "fk_rails_7342b54ced"
t.index ["tag_group_id", "parent_position"], name: "fk_rails_3af6ee71ca"
t.index ["tag_group_id", "tag_id"], name: "fk_rails_365dc60759"
t.index ["tag_id"], name: "index_tag_group_placements_on_tag_id"
t.index ["updated_by_user_id"], name: "index_tag_group_placements_on_updated_by_user_id"
t.check_constraint "(((`section_name` is not null) and (`tag_id` is null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is not null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is null) and (`child_tag_group_id` is not null))) and ((`section_name` is not null) or (`description` is null))", name: "chk_tag_group_placements_references_exclusive"
t.check_constraint "`position` > `parent_position`", name: "chk_tag_group_placements_position_gt_parent_position"
end
create_table "tag_group_tags", primary_key: ["tag_group_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.bigint "tag_id", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_group_tags_on_created_by_user_id"
t.index ["tag_id"], name: "index_tag_group_tags_on_tag_id"
end
create_table "tag_group_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.text "description"
t.json "members", null: false
t.json "placements", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id"
t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true
t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id"
t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"maxItems\":0}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}',`placements`)", name: "chk_tag_group_versions_placements_schema"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"uniqueItems\":true,\"items\":{\"oneOf\":[{\"type\":\"object\",\"required\":[\"tag_id\",\"version_no\"],\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false},{\"type\":\"object\",\"required\":[\"tag_group_id\",\"version_no\"],\"properties\":{\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false}]}}',`members`)", name: "chk_tag_group_versions_members_schema"
end
create_table "tag_groups", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.integer "version_no", null: false
t.string "name", null: false
t.text "description"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_tag_groups_on_created_by_user_id"
t.index ["name"], name: "index_tag_groups_on_name", unique: true
t.index ["updated_by_user_id"], name: "index_tag_groups_on_updated_by_user_id"
t.check_constraint "`version_no` > 0", name: "chk_tag_groups_version_no_positive"
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false t.bigint "parent_tag_id", null: false
@@ -504,17 +578,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
end 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"
@@ -551,7 +623,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) 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
@@ -559,7 +630,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
t.integer "post_count", default: 0, null: false t.integer "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
@@ -699,76 +769,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
t.index ["banned_at"], name: "index_users_on_banned_at" 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"
@@ -823,16 +823,28 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
add_foreign_key "tag_descriptions", "tags" add_foreign_key "tag_descriptions", "tags"
add_foreign_key "tag_descriptions", "users", column: "created_by_user_id" add_foreign_key "tag_descriptions", "users", column: "created_by_user_id"
add_foreign_key "tag_descriptions", "users", column: "updated_by_user_id" add_foreign_key "tag_descriptions", "users", column: "updated_by_user_id"
add_foreign_key "tag_group_groups", "tag_groups"
add_foreign_key "tag_group_groups", "tag_groups", column: "child_tag_group_id"
add_foreign_key "tag_group_groups", "users", column: "created_by_user_id"
add_foreign_key "tag_group_placements", "tag_group_groups", column: ["tag_group_id", "child_tag_group_id"], primary_key: ["tag_group_id", "child_tag_group_id"]
add_foreign_key "tag_group_placements", "tag_group_placements", column: ["tag_group_id", "parent_position"], primary_key: ["tag_group_id", "position"]
add_foreign_key "tag_group_placements", "tag_group_tags", column: ["tag_group_id", "tag_id"], primary_key: ["tag_group_id", "tag_id"]
add_foreign_key "tag_group_placements", "tag_groups"
add_foreign_key "tag_group_placements", "users", column: "created_by_user_id"
add_foreign_key "tag_group_placements", "users", column: "updated_by_user_id"
add_foreign_key "tag_group_tags", "tag_groups"
add_foreign_key "tag_group_tags", "tags"
add_foreign_key "tag_group_tags", "users", column: "created_by_user_id"
add_foreign_key "tag_groups", "users", column: "created_by_user_id"
add_foreign_key "tag_groups", "users", column: "updated_by_user_id"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
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"
@@ -857,15 +869,4 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do
add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "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
+3 -3
ファイルの表示
@@ -3,8 +3,8 @@ namespace :nico do
task export: :environment do task export: :environment do
require 'open3' require 'open3'
mysql_user = ENV.fetch('MYSQL_USER') mysql_user = ENV.fetch('NIZIKA_NICO_MYSQL_USER')
mysql_pass = ENV.fetch('MYSQL_PASS') mysql_pass = ENV.fetch('NIZIKA_NICO_MYSQL_PASS')
nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH') nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH')
videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map { videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map {
@@ -14,7 +14,7 @@ namespace :nico do
next if videos.empty? next if videos.empty?
_, stderr, status = Open3.capture3( _, stderr, status = Open3.capture3(
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, { 'NIZIKA_NICO_MYSQL_USER' => mysql_user, 'NIZIKA_NICO_MYSQL_PASS' => mysql_pass },
'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos, 'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos,
chdir: nizika_nico_path) chdir: nizika_nico_path)
+51
ファイルの表示
@@ -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
+3 -3
ファイルの表示
@@ -54,11 +54,11 @@ namespace :nico do
.find_each(&:destroy!) .find_each(&:destroy!)
end end
mysql_user = ENV['MYSQL_USER'] mysql_user = ENV['NIZIKA_NICO_MYSQL_USER']
mysql_pass = ENV['MYSQL_PASS'] mysql_pass = ENV['NIZIKA_NICO_MYSQL_PASS']
nizika_nico_path = ENV['NIZIKA_NICO_PATH'] nizika_nico_path = ENV['NIZIKA_NICO_PATH']
stdout, stderr, status = Open3.capture3( stdout, stderr, status = Open3.capture3(
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, { 'NIZIKA_NICO_MYSQL_USER' => mysql_user, 'NIZIKA_NICO_MYSQL_PASS' => mysql_pass },
'python3', "#{ nizika_nico_path }/get_videos.py") 'python3', "#{ nizika_nico_path }/get_videos.py")
unless status.success? unless status.success?
-34
ファイルの表示
@@ -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
+110
ファイルの表示
@@ -0,0 +1,110 @@
require 'rails_helper'
RSpec.describe 'Tag group persistence constraints' do
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:tag) { group_tag('制約確認') }
let!(:group_id) do
seed_group(
name: '制約確認',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }])
end
def placement attributes
insert_group_row('tag_group_placements', {
tag_group_id: group_id, position: 1,
created_at: Time.current, updated_at: Time.current }.merge(attributes))
end
def reject_statement
before_state = group_state
expect { yield }.to raise_error(ActiveRecord::StatementInvalid)
expect(group_state).to eq(before_state)
end
it 'allows membership without placement' do
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
expect(group_rows('tag_group_placements', tag_group_id: group_id)).to be_empty
end
it 'allows repeated Tag placements with unique membership' do
placement(tag_id: tag.id)
placement(tag_id: tag.id, position: 2)
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
expect(group_rows('tag_group_placements', tag_group_id: group_id).size).to eq(2)
end
it 'rejects duplicate Tag membership' do
reject_statement do
insert_group_row('tag_group_tags', {
tag_group_id: group_id, tag_id: tag.id, created_at: Time.current })
end
end
it 'rejects duplicate child membership' do
child_id = seed_group(name: '子')
attributes = { tag_group_id: group_id, child_tag_group_id: child_id,
created_at: Time.current }
insert_group_row('tag_group_groups', attributes)
reject_statement { insert_group_row('tag_group_groups', attributes) }
end
it 'rejects direct self membership' do
reject_statement do
insert_group_row('tag_group_groups', {
tag_group_id: group_id, child_tag_group_id: group_id, created_at: Time.current })
end
end
it 'rejects a placement without a kind' do
reject_statement { placement({ }) }
end
it 'rejects simultaneous Section and Tag kinds' do
reject_statement { placement(section_name: '区分', tag_id: tag.id) }
end
it 'rejects a description attached to a Tag placement' do
reject_statement { placement(tag_id: tag.id, description: '不正') }
end
it 'allows an optional description on a Section' do
placement(section_name: '区分', description: '説明')
expect(group_rows('tag_group_placements', tag_group_id: group_id).sole)
.to include('section_name' => '区分', 'description' => '説明')
end
it 'rejects a Tag placement absent from membership' do
other = group_tag('非メンバー')
reject_statement { placement(tag_id: other.id) }
end
it 'rejects a missing parent placement' do
reject_statement { placement(section_name: '子', parent_position: 9) }
end
it 'rejects a child ordered before its parent' do
placement(section_name: '親', position: 2)
reject_statement { placement(section_name: '子', position: 1, parent_position: 2) }
end
it 'rejects duplicate group names' do
reject_statement { seed_group(name: '制約確認') }
end
it 'rejects a missing mandatory group name' do
reject_statement { seed_group(name: nil) }
end
it 'rejects a six-level snapshot' do
nodes = []
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
reject_statement do
insert_group_row('tag_group_versions', {
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
placements: nodes.to_json, created_at: Time.current })
end
end
end
+16 -5
ファイルの表示
@@ -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
+23
ファイルの表示
@@ -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
+2
ファイルの表示
@@ -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
+6 -7
ファイルの表示
@@ -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
-9
ファイルの表示
@@ -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
+37
ファイルの表示
@@ -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
+48
ファイルの表示
@@ -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
+60 -19
ファイルの表示
@@ -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) }
+18 -16
ファイルの表示
@@ -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
+50
ファイルの表示
@@ -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
+41 -48
ファイルの表示
@@ -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,
+25
ファイルの表示
@@ -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
+49
ファイルの表示
@@ -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
+8 -83
ファイルの表示
@@ -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|
+63 -243
ファイルの表示
@@ -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
+20 -59
ファイルの表示
@@ -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
+122
ファイルの表示
@@ -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
+148
ファイルの表示
@@ -0,0 +1,148 @@
require 'rails_helper'
RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :request do
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:series) { group_tag('ぼざろクリーチャーシリーズ') }
let!(:deer) { group_tag('洗澡鹿') }
let!(:nizika) { group_tag('伊地知ニジカ') }
let!(:wanted) { group_tag('タグ希望') }
let!(:character) { group_tag('キャラクター名希望') }
let!(:music) { group_tag('楽曲情報希望') }
let!(:bot) { group_tag('bot操作') }
let!(:unknown) { group_tag('ニジラー情報不詳') }
let!(:child_id) { seed_group(name: '伊地知ニジカ亜種', description: '子の説明') }
before { I18n.locale = :ja }
def tag_node tag, children = []
{ 'tag_id' => tag.id, 'children' => children }
end
def tag_member tag
{ 'tag_id' => tag.id, 'version_no' => tag.version_no }
end
def check_round_trip placements:, members:, markdown:, description: nil
id = seed_group(name: '変換元', placements:, members:, description:)
without_group_legacy_reads { get group_title_path('変換元') }
expect(response).to have_http_status(:ok)
expect(json).to include('id' => id)
body = json.fetch('body')
expect(body).not_to include("\r")
expect(body.lines.map(&:chomp)).to all(satisfy { |line| line == line.rstrip })
# A trailing LF or a blank line between blocks is semantically irrelevant.
expect(body.lines.map(&:chomp).reject(&:empty?)).to eq(
markdown.lines.map(&:chomp).reject(&:empty?))
post '/wiki', params: { title: 'タググループ:再解析', body: },
headers: { 'X-Transfer-Code' => user.inheritance_code }
expect(response).to have_http_status(:created)
parsed = group_rows('tag_groups', name: '再解析').sole
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
end
it 'renders a list Section under a Tag with two-space indentation' do
check_round_trip(
members: [series, deer].map { tag_member(_1) },
placements: [tag_node(series, [
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
'children' => [tag_node(deer)] }])],
markdown: <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ亜種:
- 洗澡鹿
MARKDOWN
end
it 'renders a child Tag Group distinctly from its parent Tags' do
check_round_trip(
members: [tag_member(series), tag_member(nizika),
{ 'tag_group_id' => child_id, 'version_no' => 1 }],
placements: [tag_node(series, [tag_node(nizika, [
{ 'tag_group_id' => child_id, 'children' => [] }])])],
markdown: <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ
- タググループ:伊地知ニジカ亜種
MARKDOWN
end
it 'renders top-level heading Sections, descriptions and ordered nested Tags' do
check_round_trip(
description: '全体の説明',
members: [wanted, character, music, bot, unknown].map { tag_member(_1) },
placements: [
{ 'section_name' => '要求', 'description' => '要求の説明',
'children' => [tag_node(wanted, [tag_node(character), tag_node(music)])] },
{ 'section_name' => '情報', 'description' => nil,
'children' => [tag_node(bot), tag_node(unknown)] }],
markdown: <<~MARKDOWN)
全体の説明
## 要求
要求の説明
- タグ希望
- キャラクター名希望
- 楽曲情報希望
## 情報
- bot操作
- ニジラー情報不詳
MARKDOWN
end
it 'renders nested heading Sections and all repeated Tag placements' do
check_round_trip(
members: [tag_member(deer)],
placements: [
{ 'section_name' => '親', 'description' => '親の説明', 'children' => [
tag_node(deer),
{ 'section_name' => '子', 'description' => '子の説明',
'children' => [tag_node(deer)] }] }],
markdown: <<~MARKDOWN)
## 親
親の説明
- 洗澡鹿
### 子
子の説明
- 洗澡鹿
MARKDOWN
end
it 'round trips a top-level list followed by heading Sections' do
check_round_trip(
members: [tag_member(series), tag_member(deer)],
placements: [tag_node(series),
{ 'section_name' => '区分', 'description' => nil,
'children' => [tag_node(deer)] }],
markdown: "- ぼざろクリーチャーシリーズ\n\n## 区分\n\n- 洗澡鹿")
end
it 'rejects mixed top-level heading Sections and Tags without losing structure' do
id = seed_group(
name: '混在構造', members: [tag_member(series)],
placements: [
{ 'section_name' => '区分', 'description' => nil, 'children' => [] },
tag_node(series)])
get group_title_path('混在構造')
expect(response).to have_http_status(:unprocessable_entity)
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
end
it 'rejects membership without a placement instead of omitting it' do
id = seed_group(name: '未配置構造', members: [tag_member(series)])
get group_title_path('未配置構造')
expect(response).to have_http_status(:unprocessable_entity)
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
end
end
+179
ファイルの表示
@@ -0,0 +1,179 @@
require 'rails_helper'
RSpec.describe 'Tag group Wiki facade reads', type: :request do
include WikiRecords
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:old_tag) { group_tag('旧タグ') }
let!(:new_tag) { group_tag('新タグ') }
let!(:page) { create_markdown_group('読取り契約', '- 旧タグ') }
let!(:first_revision) { page.versions.sole }
let!(:last_revision) do
parsed = TagGroupMarkdownParser.parse(Locale.nipponese, body: '- 新タグ')
TagGroupOperation.update!(
Locale.nipponese,
group: page, name: page.name, actor: user,
**parsed.slice(:description, :members, :placements))
page.versions.order(:version_no).last
end
before { I18n.locale = :ja }
[:id, :title].each do |lookup|
context "with #{ lookup } lookup" do
let(:path) { lookup == :id ? "/wiki/#{ page.id }" : group_title_path('読取り契約') }
it 'reads current structure without consulting migrated Wiki tables' do
without_group_legacy_reads { get path }
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => page.id, 'title' => 'タググループ:読取り契約',
'revision_id' => last_revision.id, 'pred' => first_revision.id, 'succ' => nil)
expect(json.fetch('body').strip).to eq('- 新タグ')
end
it 'reads the old snapshot rather than current membership or placement' do
without_group_legacy_reads { get path, params: { version: first_revision.id } }
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq('- 旧タグ')
expect(json).to include(
'revision_id' => first_revision.id, 'pred' => nil, 'succ' => last_revision.id,
'updated_at' => first_revision.created_at.iso8601(3))
end
it 'checks existence without consulting migrated Wiki tables' do
without_group_legacy_reads { get "#{ path }/exists" }
expect(response).to have_http_status(:no_content)
end
it 'does not fall back to legacy history when a group version is missing' do
group_connection.execute(
"DELETE FROM tag_group_versions WHERE id = #{ first_revision.id }")
without_group_legacy_reads { get path, params: { version: first_revision.id } }
expect(response).to have_http_status(:not_found)
end
end
end
['/wiki', '/wiki/search'].each do |path|
it "lists migrated groups once with current titles at #{ path }" do
get path, params: { title: 'タググループ:読取り' }
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('title') == 'タググループ:読取り契約' }
expect(rows.size).to eq(1)
expect(rows.sole).to include('id' => page.id)
expect(response.body).not_to include('legacy stale')
end
end
it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do
TagDescriptionOperation.create!(
Locale.nipponese, tag: new_tag, body: 'tag description', created_by_user: user)
get '/wiki'
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include(
'タググループ:読取り契約', '新タグ')
end
it 'finds groups by a partial prefix of their displayed title' do
get '/wiki', params: { title: 'タググル' }
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include('タググループ:読取り契約')
expect(json.find { _1.fetch('title') == 'タググループ:読取り契約' })
.to include('updated_at' => be_present)
end
it 'does not restore a stale namespaced Wiki page after a group rename' do
group = create_markdown_group('旧名称', '正本')
TagGroupOperation.update!(
Locale.nipponese,
group:, name: '新名称', description: '正本', members: [], placements: [], actor: user)
get group_title_path('旧名称')
expect(response).to have_http_status(:not_found)
get "#{ group_title_path('旧名称') }/exists"
expect(response).to have_http_status(:not_found)
get group_title_path('新名称')
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq('正本')
end
it 'orders mixed resource history by timestamp rather than independent IDs' do
TagDescriptionOperation.create!(
Locale.nipponese, tag: new_tag, body: 'description', created_by_user: user)
TagGroupVersion.update_all(created_at: 3.days.ago)
TagDescriptionVersion.update_all(created_at: 1.day.ago)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
expect(timestamps).to eq(timestamps.sort.reverse)
titles = json.map { _1.dig('wiki_page', 'title') }
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
end
it 'renders nullable group editors without raising' do
group_connection.execute(
"UPDATE tag_groups SET created_by_user_id = NULL, updated_by_user_id = NULL " +
"WHERE id = #{ page.id }")
group_connection.execute(
"UPDATE tag_group_versions SET created_by_user_id = NULL " +
"WHERE tag_group_id = #{ page.id }")
get "/tag_groups/#{ page.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('created_by_user' => nil, 'updated_by_user' => nil)
get "/wiki/title/#{ CGI.escapeURIComponent('タググループ:読取り契約') }"
expect(response).to have_http_status(:ok)
end
it 'reads group version IDs, editor and timestamps in scoped changes' do
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
expect(json.first).to include(
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
'timestamp' => last_revision.created_at.iso8601(3),
'user' => { 'id' => user.id, 'name' => user.name },
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
end
[false, true].each do |explicit|
it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do
params = { from: first_revision.id }
params[:to] = last_revision.id if explicit
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
expect(response).to have_http_status(:ok)
expect(json).to include(
'wiki_page_id' => page.id, 'older_revision_id' => first_revision.id,
'newer_revision_id' => last_revision.id)
differences = json.fetch('diff').map { [_1.fetch('type'), _1.fetch('content').strip] }
expect(differences).to include(['removed', '- 旧タグ'], ['added', '- 新タグ'])
end
end
it 'does not use a version belonging to another group' do
other_id = seed_group(name: '別グループ', description: '別本文')
other_version = group_version(other_id, 1)
get group_title_path('読取り契約'), params: { version: other_version.fetch('id') }
expect(response).to have_http_status(:not_found)
get "/wiki/#{ page.id }/diff",
params: { from: other_version.fetch('id'), to: last_revision.id }
expect(response).to have_http_status(:not_found)
end
it 'resolves a group without any compatibility Wiki page' do
id = seed_group(name: '独立グループ', description: '独立した説明')
without_group_legacy_reads { get group_title_path('独立グループ') }
expect(response).to have_http_status(:ok)
expect(json).to include('id' => id, 'title' => 'タググループ:独立グループ')
expect(json.fetch('body').strip).to eq('独立した説明')
end
end
+298
ファイルの表示
@@ -0,0 +1,298 @@
require 'rails_helper'
RSpec.describe 'Tag group Wiki facade writes', type: :request do
include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers
let!(:user) { create_member_user! }
let!(:tag) { group_tag('洗澡鹿') }
let!(:other_tag) { group_tag('伊地知ニジカ') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
before { I18n.locale = :ja }
def create_group title: 'タググループ:新規', body: '- 洗澡鹿', request_headers: headers
post '/wiki', params: { title:, body: }, headers: request_headers
end
def expect_rejected_without_writes
before_state = group_state
yield
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
expect(response.media_type).to eq('application/json')
expect(response.body).not_to be_empty
next if response.body.empty?
expect(json).to include(
'type' => 'validation_error', 'errors' => a_kind_of(Hash),
'base_errors' => a_kind_of(Array))
end
end
it 'creates the group, snapshot and native version history' do
expect { create_group }
.to change(TagGroup, :count).by(1)
.and change(TagGroupVersion, :count).by(1)
expect(response).to have_http_status(:created)
group = group_rows('tag_groups', name: '新規').sole
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
expect_group_snapshot(group.fetch('id'), number: 1, members:, placements:)
version = group_version(group.fetch('id'), 1)
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
get group_title_path('新規')
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq('- 洗澡鹿')
end
it 'keeps repeated placements but only one membership and one snapshot member' do
create_group(body: "- 洗澡鹿\n# 再掲\n- 洗澡鹿")
expect(response).to have_http_status(:created)
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
expect_group_snapshot(
id, number: 1, members:,
placements: placements + [
{ 'section_name' => '再掲', 'description' => nil, 'children' => placements }])
end
it 'resolves a Japanese alias through tag_names.tag_id' do
create(:tag_name, :alias, tag:, name: '鹿の別名')
create_group(body: '- 鹿の別名')
expect(response).to have_http_status(:created)
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
expect_group_snapshot(id, number: 1, members:, placements:)
get group_title_path('新規')
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq('- 洗澡鹿')
end
{
'unknown Tag' => '- 解決不能',
'unknown Tag Group' => '- タググループ:存在しない',
'empty Section' => '- :',
'unsupported placement syntax' => "```\n洗澡鹿\n```",
'six levels' => "- 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n" +
" - 洗澡鹿\n - 洗澡鹿",
'blank body' => '',
'normalised blank body' => "\r\n\r\n" }.each do |label, body|
it "rejects #{ label } without fallback or partial create" do
expect_rejected_without_writes { create_group(body:) }
end
end
['', 'タググループ:', "タググループ:#{ '長' * 256 }"].each do |title|
it "rejects an invalid required title of length #{ title.length }" do
expect_rejected_without_writes { create_group(title:) }
end
end
it 'rejects a duplicate group name even without a matching legacy Wiki' do
seed_group(name: '新規', description: '既存')
expect_rejected_without_writes { create_group }
end
it 'accepts the existing five-level placement depth boundary' do
body = (0...5).map { "#{ ' ' * _1 }- 洗澡鹿" }.join("\n")
create_group(body:)
expect(response).to have_http_status(:created)
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
tree = []
5.times { tree = [{ 'tag_id' => tag.id, 'children' => tree }] }
expect_group_snapshot(id, number: 1, members:, placements: tree)
end
context 'updating a migrated group' do
let!(:page) { create_markdown_group('更新', '- 洗澡鹿') }
let!(:old_revision) { page.versions.order(:version_no).last }
def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
end
[1, 3].each do |repetitions|
it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do
body = group_markdown(page)
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id) do
repetitions.times do
update_group(body:)
expect(response).to have_http_status(:ok)
end
end
end
end
end
it 'keeps identical Markdown unchanged with a stale base_revision_id' do
update_group
expect(response).to have_http_status(:ok)
body = group_markdown(page)
expect(page.versions.order(:version_no).last.id).not_to eq(old_revision.id)
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id) do
put "/wiki/#{ page.id }",
params: { title: "タググループ:#{ page.name }", body:, base_revision_id: old_revision.id },
headers: headers
end
end
end
it 'keeps the original editor on identical Markdown from another member' do
editor = create(:user, :member)
body = group_markdown(page)
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id) do
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
end
end
end
it 'does not add history for Wiki content already changed through Native' do
changed_members = [{ 'tag_id' => other_tag.id }]
changed_placements = [{ 'tag_id' => other_tag.id, 'children' => [] }]
put "/tag_groups/#{ page.id }",
params: { name: '更新', description: nil,
members: changed_members, placements: changed_placements },
headers:, as: :json
expect(response).to have_http_status(:ok)
expect_group_snapshot(
page.id, number: 2,
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
placements: changed_placements)
body = group_markdown(page)
expect(body).to eq('- 伊地知ニジカ')
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id) { update_group(body:) }
end
end
it 'replaces current membership and placement, retaining the old snapshot' do
old = group_version(page.id, 1)
update_group
expect(response).to have_http_status(:ok)
expect_group_snapshot(
page.id, number: 2,
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
placements: [{ 'tag_id' => other_tag.id, 'children' => [] }])
expect(group_version(page.id, 1)).to eq(old)
expect(group_version(page.id, 2)).to include(
'event_type' => 'update', 'created_by_user_id' => user.id)
expect(group_markdown(page).strip).to eq('- 伊地知ニジカ')
expect(page.versions.count).to eq(2)
end
it 'accepts the current base_revision_id used by the existing frontend' do
put "/wiki/#{ page.id }",
params: { title: "タググループ:#{ page.name }", body: '- 伊地知ニジカ',
base_revision_id: old_revision.id }, headers: headers
expect(response).to have_http_status(:ok)
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2)
end
it 'ignores a stale base_revision_id without rejecting the update' do
update_group
expect(response).to have_http_status(:ok)
put "/wiki/#{ page.id }",
params: { title: "タググループ:#{ page.name }", body: '- 洗澡鹿',
base_revision_id: old_revision.id }, headers: headers
expect(response).to have_http_status(:ok)
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(3)
expect(group_markdown(page).strip).to eq('- 洗澡鹿')
end
it 'records the editor and update time' do
editor = create(:user, :member, name: '編輯者')
travel_to(Time.current + 1.hour) do
update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
expect(response).to have_http_status(:ok)
current = group_rows('tag_groups', id: page.id).sole
version = group_version(page.id, 2)
expect(current).to include('updated_by_user_id' => editor.id)
expect(version).to include('created_by_user_id' => editor.id)
expect(current.fetch('updated_at')).to be_within(1.second).of(Time.current)
expect(version.fetch('created_at')).to be_within(1.second).of(Time.current)
end
end
['- 不明なタグ', '- タググループ:不明', '', '- :'].each do |body|
it "rejects invalid update #{ body.inspect } atomically" do
expect_rejected_without_writes { update_group(body:) }
end
end
it 'rejects direct self inclusion' do
expect_rejected_without_writes { update_group(body: '- タググループ:更新') }
end
it 'rejects indirect cyclic membership' do
seed_group(
name: '子', members: [{ 'tag_group_id' => page.id, 'version_no' => 1 }],
placements: [{ 'tag_group_id' => page.id, 'children' => [] }])
expect_rejected_without_writes { update_group(body: '- タググループ:子') }
end
it 'rejects duplicate rename without retaining a partial title or version' do
seed_group(name: '既存', description: '既存')
expect_rejected_without_writes { update_group(title: 'タググループ:既存') }
end
end
[:create, :update].each do |operation|
context "#{ operation } access and atomicity" do
let!(:page) do
next unless operation == :update
create_markdown_group('更新', '- 洗澡鹿')
end
let!(:child_id) { seed_group(name: '子グループ', description: '子') }
def write_group operation, request_headers
body = "- 伊地知ニジカ\n- タググループ:子グループ"
if operation == :create
create_group(body:, request_headers:)
else
put "/wiki/#{ page.id }",
params: { title: 'タググループ:更新', body: }, headers: request_headers
end
end
[nil, :guest, :banned].each do |role|
it "rejects #{ role || 'anonymous' } without writes" do
actor = case role
when :guest then create(:user, :guest)
when :banned then create(:user, :member, :banned)
end
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
before_state = group_state
write_group(operation, request_headers)
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
expect(group_state).to eq(before_state)
end
end
it 'rejects a banned IP even for a member' do
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
before_state = group_state
write_group(operation, headers)
expect(response).to have_http_status(:forbidden)
expect(group_state).to eq(before_state)
end
tables = TagGroupRecords::GROUP_TABLES
tables.each do |table|
it "rolls back all writes after a real #{ table } write fails" do
expect_atomic_group_write(table) { write_group(operation, headers) }
end
end
end
end
end
+204
ファイルの表示
@@ -0,0 +1,204 @@
require 'rails_helper'
RSpec.describe 'Tag group write integrity', type: :request do
include WikiRecords
include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers
let!(:user) { create_member_user! }
let!(:tag) { group_tag('洗澡鹿') }
let!(:other_tag) { group_tag('伊地知ニジカ') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
before { I18n.locale = :ja }
def native_payload name: '更新', nodes: placements, member_rows: members
{ name:, description: nil, members: member_rows.map do |member|
member.except('version_no')
end, placements: nodes }
end
def put_wiki id, title:, body: '- 伊地知ニジカ', base: nil
put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers
end
def expect_no_writes
before_state = group_state
yield
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
context 'resource identity' do
it 'rejects an unprefixed update to an independent group' do
id = seed_group(name: '独立', members:, placements:)
expect_no_writes { put_wiki(id, title: '独立') }
end
it 'renames the group and its facade title through the operation' do
page = create_markdown_group('更新', '- 洗澡鹿')
put_wiki(page.id, title: 'タググループ:改名')
expect(response).to have_http_status(:ok)
expect(TagGroup.find(page.id)).to have_attributes(name: '改名', version_no: 2)
expect("タググループ:#{ page.reload.name }").to eq('タググループ:改名')
expect(group_markdown(page)).to eq('- 伊地知ニジカ')
end
it 'keeps a description update separate from a colliding group' do
page = seed_description(
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
description = TagDescription.find_by!(tag_id: tag.id)
id = seed_group(name: '更新', id: description.id, members:, placements:)
group_before = TagGroup.find(id).attributes
put_wiki(id, title: '洗澡鹿', body: 'description after')
expect(response).to have_http_status(:ok)
expect(TagDescription.find(id).body).to eq('description after')
expect(page.reload.body).to eq('description after')
expect(TagGroup.find(id).attributes).to eq(group_before)
end
end
it 'rolls back a facade create when its saved structure cannot be rendered' do
allow(TagGroupMarkdownRenderer).to receive(:render)
.and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported')
expect_no_writes do
post '/wiki', params: { title: 'タググループ:新規', body: '- 洗澡鹿' }, headers: headers
end
end
it 'rolls back a facade update when its saved structure cannot be rendered' do
id = seed_group(name: '更新', members:, placements:)
allow(TagGroupMarkdownRenderer).to receive(:render)
.and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported')
expect_no_writes { put_wiki(id, title: 'タググループ:改名') }
end
context 'identifier Markdown' do
['[洗澡鹿](https://example.com)', '*洗澡鹿*', '**洗澡鹿**', '`洗澡鹿`',
'![洗澡鹿](https://example.com/image.png)',
'[タググループ:子](https://example.com)', '*区分*:',
'[区分](https://example.com):'].each do |label|
[:create, :update].each do |operation|
it "rejects decorated #{ label } on #{ operation } without writes" do
seed_group(name: '子', description: '子')
id = seed_group(name: '更新', members:, placements:)
expect_no_writes do
if operation == :create
post '/wiki', params: { title: 'タググループ:新規', body: "- #{ label }" },
headers: headers
else
put_wiki(id, title: 'タググループ:更新', body: "- #{ label }")
end
end
end
end
end
['# [区分](https://example.com)', '# *区分*', '# `区分`', '# 区分 {#custom}',
'# ![区分](https://example.com/image.png)'].each do |heading|
it "rejects a decorated heading #{ heading } without writes" do
expect_no_writes do
post '/wiki', params: { title: 'タググループ:新規', body: "#{ heading }\n- 洗澡鹿" },
headers: headers
end
end
end
it 'preserves allowed markup in group and heading descriptions' do
description = '[参照](https://example.com "リンクの説明") と *強調* と ``a`b``'
body = "#{ description }\n\n# 区分\n#{ description }\n\n- 洗澡鹿"
post '/wiki', params: { title: 'タググループ:説明構文', body: }, headers: headers
expect(response).to have_http_status(:created)
id = json.fetch('id')
version = group_version(id, 1)
expect(version.fetch('description')).to eq(description)
expect(version.fetch('placements').sole.fetch('description')).to eq(description)
end
end
context 'structure audit information' do
let!(:child_id) { seed_group(name: '子', description: '子') }
let(:members) do
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
end
let(:placements) do
[{ 'tag_id' => tag.id, 'children' => [
{ 'tag_group_id' => child_id, 'children' => [] }] }]
end
let!(:id) { seed_group(name: '更新', members:, placements:) }
let!(:editor) { create(:user, :member) }
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
it 'keeps all membership and placement audit fields on a name-only edit' do
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
travel_to(1.hour.from_now) do
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
end
expect(response).to have_http_status(:ok)
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
expect(group_version(id, 2)).to include('name' => '改名', 'members' => members)
end
it 'keeps membership and placement audit fields on a description-only edit' do
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
travel_to(1.hour.from_now) do
put "/tag_groups/#{ id }",
params: native_payload.merge(description: '説明を追加'), headers:, as: :json
end
expect(response).to have_http_status(:ok)
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members)
end
it 'rolls back membership removal and placement edits on a version persistence failure' do
new_members = [{ 'tag_id' => tag.id }]
new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }]
expect_atomic_group_write('tag_group_versions') do
put "/tag_groups/#{ id }",
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
end
end
it 'changes only added or removed membership and keeps placement creation audit' do
tag_row = TagGroupTag.find([id, tag.id]).attributes
placement_row = TagGroupPlacement.find([id, 1]).attributes
new_members = [{ 'tag_id' => tag.id }, { 'tag_id' => other_tag.id }]
new_nodes = [{ 'tag_id' => tag.id, 'children' => [
{ 'tag_id' => other_tag.id, 'children' => [] }] }]
child_row = TagGroupPlacement.find([id, 2]).attributes
travel_to(1.hour.from_now) do
put "/tag_groups/#{ id }",
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
end
expect(response).to have_http_status(:ok)
expect(TagGroupTag.find([id, tag.id]).attributes).to eq(tag_row)
expect(TagGroupGroup.exists?(tag_group_id: id, child_tag_group_id: child_id)).to be(false)
expect(TagGroupTag.find([id, other_tag.id]).created_by_user_id).to eq(editor.id)
expect(TagGroupPlacement.find([id, 1]).attributes).to eq(placement_row)
changed = TagGroupPlacement.find([id, 2])
expect(changed).to have_attributes(
tag_id: other_tag.id, child_tag_group_id: nil, updated_by_user_id: editor.id,
created_at: child_row.fetch('created_at'),
created_by_user_id: child_row.fetch('created_by_user_id'))
expect(changed.updated_at).to be > child_row.fetch('updated_at')
expect(group_version(id, 1).fetch('members')).to eq(members)
end
end
end
+447
ファイルの表示
@@ -0,0 +1,447 @@
require 'rails_helper'
RSpec.describe 'Tag group native API', type: :request do
include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers
let!(:user) { create_member_user! }
let!(:group_id) { seed_group(name: '専用API', description: '説明') }
before { I18n.locale = :ja }
def expect_native_group value
expect(value).to include(
'id' => group_id, 'name' => '専用API', 'description' => '説明',
'version_no' => 1, 'members' => [], 'placements' => [],
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
current = group_rows('tag_groups', id: group_id).sole
expect(value).to include(
'created_at' => current.fetch('created_at').iso8601(3),
'updated_at' => current.fetch('updated_at').iso8601(3))
end
it 'serves the collection as JSON without reading Wiki tables' do
without_group_legacy_reads { get '/tag_groups' }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('application/json')
expect(json).to be_a(Array)
expect_native_group(json.find { _1.fetch('id') == group_id })
end
it 'serves an independent group as JSON without reading Wiki tables' do
without_group_legacy_reads { get "/tag_groups/#{ group_id }" }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('application/json')
expect_native_group(json)
end
it 'provides the confirmed PUT update route' do
route = Rails.application.routes.recognize_path("/tag_groups/#{ group_id }", method: :put)
expect(route).to include(action: 'update', id: group_id.to_s)
end
[:create, :update].each do |operation|
[nil, :guest, :banned].each do |role|
it "rejects #{ role || 'anonymous' } native #{ operation } without writes" do
actor = case role
when :guest then create(:user, :guest)
when :banned then create(:user, :member, :banned)
end
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
before_state = group_state
if operation == :create
post '/tag_groups', params: { }, headers:, as: :json
else
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
end
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
expect(group_state).to eq(before_state)
end
end
it "rejects a banned IP for native #{ operation }" do
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
headers = { 'X-Transfer-Code' => user.inheritance_code }
before_state = group_state
if operation == :create
post '/tag_groups', params: { }, headers:, as: :json
else
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
end
expect(response).to have_http_status(:forbidden)
expect(group_state).to eq(before_state)
end
end
context 'structured membership and placements' do
let!(:tag) { group_tag('掲載タグ') }
let!(:unplaced) { group_tag('未掲載タグ') }
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) do
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
end
let(:snapshot_members) do
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
{ 'tag_group_id' => child_id, 'version_no' => 1 },
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }]
end
let(:placements) do
[{ 'section_name' => '区分', 'description' => '区分の説明', 'children' => [
{ 'tag_id' => tag.id, 'children' => [
{ 'tag_group_id' => child_id, 'children' => [] }] },
{ 'tag_id' => tag.id, 'children' => [] }] }]
end
let(:payload) { { name: '構造化', description: '全体の説明', members:, placements: } }
def native_write operation, attributes = payload
if operation == :create
post '/tag_groups', params: attributes, headers:, as: :json
else
put "/tag_groups/#{ group_id }", params: attributes, headers:, as: :json
end
end
def expect_structured_response
tag_repr = { 'id' => tag.id, 'name' => '掲載タグ', 'category' => tag.category }
unplaced_repr = { 'id' => unplaced.id, 'name' => '未掲載タグ',
'category' => unplaced.category }
expect(json.fetch('members')).to contain_exactly(
include('tag' => include(tag_repr)),
include('tag' => include(unplaced_repr)),
include('tag_group' => include('id' => child_id, 'name' => '子')))
expect(json.fetch('placements')).to match([
include('section_name' => '区分', 'description' => '区分の説明', 'children' => [
include('tag' => include(tag_repr), 'children' => [
include('tag_group' => include('id' => child_id, 'name' => '子'),
'children' => [])]),
include('tag' => include(tag_repr), 'children' => [])])])
end
[:create, :update].each do |operation|
it "#{ operation }s structured data and keeps unplaced membership" do
previous = group_version(group_id, 1)
native_write(operation)
expect(response).to have_http_status(operation == :create ? :created : :ok)
id = operation == :create ? json.fetch('id') : group_id
number = operation == :create ? 1 : 2
expect_group_snapshot(
id, number:, members: snapshot_members, placements:, description: '全体の説明')
expect(group_version(group_id, 1)).to eq(previous)
get "/tag_groups/#{ id }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => id, 'name' => '構造化', 'description' => '全体の説明', 'version_no' => number,
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
expect_structured_response
end
{
'blank name' => { name: '' },
'missing name' => { name: nil },
'oversized name' => { name: '長' * 256 },
'duplicate name' => { name: '子' },
'placement without a kind' => { placements: [{ children: [] }] },
'empty Section name' => { placements: [{ section_name: '', children: [] }] } }
.each do |label, invalid|
it "rejects #{ label } on #{ operation } without partial writes" do
before_state = group_state
native_write(operation, payload.merge(invalid))
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
end
it "rejects multiple placement kinds on #{ operation }" do
before_state = group_state
invalid = [{ tag_id: tag.id, section_name: '不正', children: [] }]
native_write(operation, payload.merge(placements: invalid))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it "rejects a missing referenced Tag on #{ operation }" do
missing = Tag.maximum(:id).to_i + 1
before_state = group_state
native_write(operation, payload.merge(
members: [{ tag_id: missing }], placements: [{ tag_id: missing, children: [] }]))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it "rejects a missing referenced group on #{ operation }" do
missing = group_rows('tag_groups').map { _1.fetch('id') }.max + 1
before_state = group_state
native_write(operation, payload.merge(
members: [{ tag_group_id: missing }],
placements: [{ tag_group_id: missing, children: [] }]))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it "rejects six descendant placement levels on #{ operation }" do
nodes = []
6.times { nodes = [{ tag_id: tag.id, children: nodes }] }
before_state = group_state
native_write(operation, payload.merge(placements: nodes))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
it 'preserves membership-only data in structured reads without inventing placements' do
id = seed_group(name: '未掲載のみ', members: [snapshot_members.last])
without_group_legacy_reads { get "/tag_groups/#{ id }" }
expect(response).to have_http_status(:ok)
expect(json.fetch('placements')).to eq([])
expect(json.fetch('members')).to match([
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
end
it 'rejects indirect containment cycles' do
parent = seed_group(
name: '親', members: [{ 'tag_group_id' => group_id, 'version_no' => 1 }])
before_state = group_state
native_write(:update, payload.merge(
members: [{ tag_group_id: parent }], placements: []))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it 'rejects direct containment even without a corresponding placement' do
before_state = group_state
native_write(:update, payload.merge(
members: [{ tag_group_id: group_id }], placements: []))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it 'preserves list Section descriptions in native structured data' do
nodes = [{ tag_id: tag.id, children: [
{ section_name: 'リスト内区分', description: '消してはいけない説明', children: [] }] }]
native_write(:update, payload.merge(placements: nodes))
expect(response).to have_http_status(:ok)
expect(group_version(group_id, 2).fetch('placements')).to eq(
JSON.parse(nodes.to_json))
get "/tag_groups/#{ group_id }"
expect(response).to have_http_status(:ok)
section = json.fetch('placements').sole.fetch('children').sole
expect(section).to include(
'section_name' => 'リスト内区分', 'description' => '消してはいけない説明')
end
context 'compatibility writes for representable structures' do
let(:group_id) do
seed_group(name: '専用API', description: '説明')
end
let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] }
let(:snapshot_members) do
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
end
it 'treats identical native content with unplaced membership as a no-op' do
unplaced_payload = payload.merge(
name: '専用API', members: members + [{ 'tag_id' => unplaced.id }])
native_write(:update, unplaced_payload)
expect(response).to have_http_status(:ok)
expect_group_snapshot(
group_id, number: 2, description: '全体の説明', placements:,
members: snapshot_members + [
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }])
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) do
native_write(:update, unplaced_payload)
end
end
end
context 'no-op updates against synchronised state' do
let(:synchronised_payload) do
{ name: '構造化', description: '全体の説明',
members: [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }], placements: }
end
let(:additional_tag) { group_tag('追加対象タグ') }
before do
native_write(:update, synchronised_payload)
expect(response).to have_http_status(:ok)
expect(group_markdown(TagGroup.find(group_id)))
.to include('- 掲載タグ', '- タググループ:子')
end
[1, 3].each do |repetitions|
it "keeps identical native content unchanged after #{ repetitions } PUTs" do
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) do
repetitions.times do
native_write(:update, synchronised_payload)
expect(response).to have_http_status(:ok)
end
end
end
end
end
it 'treats membership order alone as a no-op' do
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) do
native_write(:update, synchronised_payload.merge(
members: synchronised_payload.fetch(:members).reverse))
end
end
end
it 'keeps the original editor when another member submits identical content' do
editor = create(:user, :member)
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) do
put "/tag_groups/#{ group_id }", params: synchronised_payload,
headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json
end
end
end
it 'does not add history for Native content already changed through Wiki' do
put "/wiki/#{ group_id }",
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
expect(response).to have_http_status(:ok)
expect(group_markdown(TagGroup.find(group_id))).to eq('- 未掲載タグ')
same_content = {
name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }],
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] }
expect_group_snapshot(
group_id, number: 3, description: nil,
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
placements: same_content.fetch(:placements))
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) do
native_write(:update, same_content)
end
end
end
['name', 'description', 'membership addition', 'membership removal',
'Section name', 'Section description', 'placement order', 'placement hierarchy']
.each do |change|
it "records a real #{ change } change in native history" do
changed = synchronised_payload.deep_dup
section = changed.fetch(:placements).first
children = section.fetch('children')
case change
when 'name' then changed[:name] = '実際の改名'
when 'description' then changed[:description] = '実際の説明変更'
when 'membership addition'
expect(changed.fetch(:members)).not_to include('tag_id' => additional_tag.id)
changed[:members] << { 'tag_id' => additional_tag.id }
children << { 'tag_id' => additional_tag.id, 'children' => [] }
when 'membership removal'
changed[:members].reject! { _1.key?('tag_group_id') }
children.first['children'] = []
when 'Section name' then section['section_name'] = '別区分'
when 'Section description' then section['description'] = '別の区分説明'
when 'placement order' then children.reverse!
when 'placement hierarchy'
children << children.first.fetch('children').pop
end
old_versions = group_rows('tag_group_versions', tag_group_id: group_id)
group = TagGroup.find(group_id)
number = group.version_no
travel_to(1.hour.from_now) { native_write(:update, changed) }
expect(response).to have_http_status(:ok)
expected_members = changed.fetch(:members).map do |member|
target = if member.key?('tag_id')
Tag.find(member.fetch('tag_id'))
else
TagGroup.find(member.fetch('tag_group_id'))
end
member.merge('version_no' => target.version_no)
end
expect_group_snapshot(
group_id, number: number + 1, members: expected_members,
placements: changed.fetch(:placements), description: changed.fetch(:description))
expect(group.reload.name).to eq(changed.fetch(:name))
versions = group_rows('tag_group_versions', tag_group_id: group_id)
expect(versions.size).to eq(old_versions.size + 1)
expect(versions.select { _1.fetch('version_no') <= number }).to eq(old_versions)
get group_title_path(changed.fetch(:name))
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq(group_markdown(group))
end
end
end
it 'creates native state and its native history' do
expect { native_write(:create) }
.to change(TagGroup, :count).by(1)
.and change(TagGroupVersion, :count).by(1)
expect(response).to have_http_status(:created)
expect_group_snapshot(
json.fetch('id'), number: 1, members: snapshot_members,
placements:, description: '全体の説明')
end
[:create, :update].each do |operation|
it "synchronises Wiki current state and histories on native #{ operation }" do
native_write(operation)
expect(response).to have_http_status(operation == :create ? :created : :ok)
id = operation == :create ? json.fetch('id') : group_id
number = operation == :create ? 1 : 2
expect_group_snapshot(
id, number:, members: snapshot_members, placements:, description: '全体の説明')
expect(TagGroup.find(id).versions.count).to eq(number)
get group_title_path('構造化')
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to include('全体の説明', '# 区分', '- 掲載タグ')
end
TagGroupRecords::GROUP_TABLES
.each do |table|
it "rolls back native #{ operation } after #{ table } writes" do
expect_atomic_group_write(table) { native_write(operation) }
end
end
end
it 'assigns the next group ID above the existing maximum' do
child_id
expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1
native_write(:create)
expect(response).to have_http_status(:created)
expect(json.fetch('id')).to be >= expected_id
end
TagGroupRecords::GROUP_TABLES.each do |table|
it "rolls back native creation after #{ table } writes" do
expect_atomic_group_write(table) { native_write(:create) }
end
end
it 'updates a native group through Wiki and then reads structured current data' do
native_write(:create)
expect(response).to have_http_status(:created)
id = json.fetch('id')
previous = group_version(id, 1)
put "/wiki/#{ id }",
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
expect(response).to have_http_status(:ok)
expect_group_snapshot(
id, number: 2,
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }])
expect(group_version(id, 1)).to eq(previous)
get "/tag_groups/#{ id }"
expect(response).to have_http_status(:ok)
expect(json).to include('version_no' => 2)
expect(json.fetch('members')).to match([
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
end
end
end
end
+61
ファイルの表示
@@ -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
+7 -5
ファイルの表示
@@ -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 }
+16 -15
ファイルの表示
@@ -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
+50 -37
ファイルの表示
@@ -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
+13 -13
ファイルの表示
@@ -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
+47
ファイルの表示
@@ -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
-198
ファイルの表示
@@ -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
+42
ファイルの表示
@@ -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
+158
ファイルの表示
@@ -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
+50 -150
ファイルの表示
@@ -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)
+16 -18
ファイルの表示
@@ -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
+16 -106
ファイルの表示
@@ -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
+156
ファイルの表示
@@ -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
+193
ファイルの表示
@@ -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
-173
ファイルの表示
@@ -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
-150
ファイルの表示
@@ -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
-99
ファイルの表示
@@ -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
+22
ファイルの表示
@@ -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
+194
ファイルの表示
@@ -0,0 +1,194 @@
require 'cgi'
# SQL fixtures use the migration contract without inventing application models.
# Include explicitly; these helpers must not affect unrelated examples.
module TagGroupRecords
GROUP_TABLES = [
'tag_groups', 'tag_group_tags', 'tag_group_groups',
'tag_group_placements', 'tag_group_versions'].freeze
WRITE_TABLES = GROUP_TABLES + [
'tag_names', 'tag_description_versions',
'tag_descriptions']
def group_connection = ApplicationRecord.connection
def group_rows table, conditions = { }
clauses = conditions.map do |column, value|
"#{ group_connection.quote_column_name(column) } = #{ group_connection.quote(value) }"
end
sql = "SELECT * FROM #{ group_connection.quote_table_name(table) }"
sql += " WHERE #{ clauses.join(' AND ') }" if clauses.any?
group_connection.select_all(sql).to_a.sort_by { |row| row.values.map(&:to_s) }
end
def insert_group_row table, attributes
columns = attributes.keys.map { group_connection.quote_column_name(_1) }.join(', ')
values = attributes.values.map { group_connection.quote(_1) }.join(', ')
group_connection.insert(
"INSERT INTO #{ group_connection.quote_table_name(table) } (#{ columns }) " +
"VALUES (#{ values })")
end
def group_state
WRITE_TABLES.to_h { |table| [table, group_rows(table)] }
end
def expect_noop_group_update group_id:
before_state = group_state
group = TagGroup.find(group_id)
version_id = TagGroupVersion.find_by!(
tag_group_id: group_id, version_no: group.version_no).id
writes = []
tables = Regexp.union(WRITE_TABLES)
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
if sql.match?(/\A\s*(?:INSERT INTO|UPDATE|DELETE FROM)\s+`?(?:#{ tables })`?\s/i)
writes << sql
end
end
aggregate_failures 'no-op current state, history and SQL writes' do
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
expect(response).to have_http_status(:ok)
after_state = group_state
before_state.each do |table, rows|
expect(after_state.fetch(table)).to eq(rows), "No-op changed #{ table }"
end
expect(TagGroupVersion.find_by!(
tag_group_id: group_id, version_no: group.reload.version_no).id).to eq(version_id)
expect(writes).to be_empty
end
end
def expect_atomic_group_write table
before_state = group_state
failure = Class.new(StandardError)
injected = false
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
next unless sql.match?(/\A\s*(?:INSERT INTO|UPDATE)\s+`?#{ table }`?\s/i)
injected = true
raise failure, 'injected after SQL write'
end
begin
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
rescue failure
# Inspect the database after the operation's transaction unwinds.
end
aggregate_failures do
expect(injected).to be(true), "No INSERT/UPDATE reached #{ table }"
expect(group_state).to eq(before_state)
end
end
def group_version group_id, number
row = group_rows('tag_group_versions', tag_group_id: group_id, version_no: number).sole
row.merge('members' => JSON.parse(row.fetch('members')),
'placements' => JSON.parse(row.fetch('placements')))
end
def group_tag name
tag = create(:tag, primary_name: name)
TagVersioning.ensure_snapshot!(tag, created_by_user: user)
tag.reload
end
def create_markdown_group name, body, locale: Locale.nipponese
parsed = TagGroupMarkdownParser.parse(locale, body:)
TagGroupOperation.create!(
locale, name:, actor: user, **parsed.slice(:description, :members, :placements))
end
def group_markdown group, locale: Locale.nipponese
TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group.reload))
end
def seed_group name:, placements: [], members: [], description: nil, id: nil
now = Time.current.change(usec: 0)
attributes = {
name:, description:, version_no: 1,
created_by_user_id: user.id, updated_by_user_id: user.id,
created_at: now, updated_at: now }
attributes[:id] = id if id
group_id = insert_group_row('tag_groups', attributes)
insert_group_row('tag_group_versions', {
tag_group_id: group_id, version_no: 1, event_type: 'create',
name:, description:, members: members.to_json, placements: placements.to_json,
created_by_user_id: user.id, created_at: now })
members.each do |member|
tag_member = member.key?('tag_id')
table = tag_member ? 'tag_group_tags' : 'tag_group_groups'
key = tag_member ? :tag_id : :child_tag_group_id
value = member.fetch(tag_member ? 'tag_id' : 'tag_group_id')
insert_group_row(table, {
tag_group_id: group_id, key => value,
created_by_user_id: user.id, created_at: now })
end
position = 0
append = lambda do |nodes, parent|
nodes.each do |node|
position += 1
own_position = position
insert_group_row('tag_group_placements', {
tag_group_id: group_id, position:, parent_position: parent,
section_name: node['section_name'], description: node['description'],
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'],
created_by_user_id: user.id, updated_by_user_id: user.id,
created_at: now, updated_at: now })
append.call(node.fetch('children'), own_position)
end
end
append.call(placements, nil)
group_id
end
def without_group_legacy_reads
queries = []
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
if (sql.match?(/\bSELECT\b/i) &&
sql.match?(/\bwiki_(pages|revisions|versions|lines|revision_lines)\b/i))
queries << sql
end
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
expect(queries).to be_empty
end
def group_title_path name
"/wiki/title/#{ CGI.escapeURIComponent("タググループ:#{ name }") }"
end
def expect_group_snapshot group_id, number:, placements:, members:, description: nil
current = group_rows('tag_groups', id: group_id).sole
version = group_version(group_id, number)
expect(current).to include('version_no' => number, 'description' => description),
@group_migration_messages&.join("\n")
expect(version).to include(
'name' => current.fetch('name'), 'description' => description,
'version_no' => number, 'placements' => placements)
expect(version.fetch('members')).to match_array(members)
expect(group_rows('tag_group_tags', tag_group_id: group_id).map { _1.fetch('tag_id') })
.to match_array(members.filter_map { _1['tag_id'] })
children = group_rows('tag_group_groups', tag_group_id: group_id)
expect(children.map { _1.fetch('child_tag_group_id') })
.to match_array(members.filter_map { _1['tag_group_id'] })
rows = group_rows('tag_group_placements', tag_group_id: group_id)
tree = lambda do |parent|
rows.select { _1['parent_position'] == parent }.sort_by { _1.fetch('position') }
.map do |row|
node = if row['tag_id']
{ 'tag_id' => row.fetch('tag_id') }
elsif row['child_tag_group_id']
{ 'tag_group_id' => row.fetch('child_tag_group_id') }
else
{ 'section_name' => row.fetch('section_name'), 'description' => row['description'] }
end
node.merge('children' => tree.call(row.fetch('position')))
end
end
expect(tree.call(nil)).to eq(placements)
end
end
+14
ファイルの表示
@@ -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
+4 -4
ファイルの表示
@@ -18,8 +18,8 @@ RSpec.describe 'nico:export' do
before do before do
task.reenable task.reenable
allow(ENV).to receive(:fetch).with('MYSQL_USER').and_return('mysql-user') allow(ENV).to receive(:fetch).with('NIZIKA_NICO_MYSQL_USER').and_return('mysql-user')
allow(ENV).to receive(:fetch).with('MYSQL_PASS').and_return('mysql-pass') allow(ENV).to receive(:fetch).with('NIZIKA_NICO_MYSQL_PASS').and_return('mysql-pass')
allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico') allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico')
end end
@@ -33,8 +33,8 @@ RSpec.describe 'nico:export' do
expect(Open3).to receive(:capture3) do |env, *args, **kwargs| expect(Open3).to receive(:capture3) do |env, *args, **kwargs|
expect(env).to eq( expect(env).to eq(
{ {
'MYSQL_USER' => 'mysql-user', 'NIZIKA_NICO_MYSQL_USER' => 'mysql-user',
'MYSQL_PASS' => 'mysql-pass', 'NIZIKA_NICO_MYSQL_PASS' => 'mysql-pass',
}, },
) )
+2
ファイルの表示
@@ -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"
+1
ファイルの表示
@@ -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": {
+112
ファイルの表示
@@ -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')
})
})
+192
ファイルの表示
@@ -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
+115 -30
ファイルの表示
@@ -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')}>
その他 &raquo; その他 &raquo;
</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

変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示