Reviewed-on: #429 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #429 でマージされました。
このコミットが含まれているのは:
@@ -0,0 +1,39 @@
|
||||
class TagGroupsController < ApplicationController
|
||||
def index
|
||||
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
|
||||
render json: TagGroupRepr.many(groups.to_a)
|
||||
end
|
||||
|
||||
def show
|
||||
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
render json: TagGroupRepr.one(group)
|
||||
end
|
||||
|
||||
def create
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
group = TagGroupOperation.create!(**write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(group), status: :created
|
||||
end
|
||||
|
||||
def update
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
|
||||
group = TagGroup.find_by(id: params[:id])
|
||||
return head :not_found unless group
|
||||
|
||||
TagGroupOperation.update!(group:, **write_attributes, actor: current_user)
|
||||
render json: TagGroupRepr.one(group.reload)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_attributes
|
||||
{ name: params[:name], description: params[:description],
|
||||
members: params[:members], placements: params[:placements] }
|
||||
end
|
||||
end
|
||||
@@ -3,23 +3,27 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
def index
|
||||
# 互換性のための化石リソース
|
||||
# TODO: ヘルプおよびタグ・グループ追加後に,
|
||||
# TODO: タグ説明とヘルプ、タグ・グループから取得する作りに変更する.
|
||||
#
|
||||
|
||||
title = params[:title].to_s.strip
|
||||
descriptions = TagDescription.where(language_code: 'ja')
|
||||
descriptions =
|
||||
TagDescription.where(language_code: 'ja')
|
||||
.includes(tag: :tag_names)
|
||||
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
|
||||
|
||||
tag_groups = TagGroup.all
|
||||
if title.present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||
descriptions = descriptions.joins(tag: :tag_names)
|
||||
descriptions =
|
||||
descriptions.joins(tag: :tag_names)
|
||||
.where(tag_names: { language_code: 'ja', primary_flg: true })
|
||||
.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||
tag_groups = tag_groups.where(
|
||||
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
|
||||
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||
end
|
||||
|
||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
||||
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||
json.concat(WikiPageRepr.base(pages))
|
||||
json = json.first(20) if title.present?
|
||||
render json:
|
||||
@@ -30,6 +34,9 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||
|
||||
tag_group = TagGroup.find_by(id: params[:id])
|
||||
return render_tag_group tag_group if tag_group
|
||||
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(id: params[:id])
|
||||
@@ -40,25 +47,39 @@ class WikiPagesController < ApplicationController
|
||||
def show_by_title
|
||||
title = params[:title].to_s.strip
|
||||
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||
tag_group = TagGroup.find_by(name:)
|
||||
return render_tag_group tag_group if tag_group
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
tag_description = tag_description_by_title(title)
|
||||
|
||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(tag_name: { name: title })
|
||||
page =
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(tag_name: { name: title })
|
||||
|
||||
render_wiki_page_or_404 page
|
||||
end
|
||||
|
||||
def exists
|
||||
return head :no_content if TagDescription.exists?(id: params[:id])
|
||||
return head :no_content if TagGroup.exists?(id: params[:id])
|
||||
|
||||
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
|
||||
end
|
||||
|
||||
def exists_by_title
|
||||
title = params[:title].to_s.strip
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX))
|
||||
return head exists ? :no_content : :not_found
|
||||
end
|
||||
return head :no_content if tag_description_by_title(title)
|
||||
|
||||
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
||||
@@ -79,29 +100,33 @@ class WikiPagesController < ApplicationController
|
||||
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
|
||||
resource = WikiPageRepr.tag_description(description)
|
||||
else
|
||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
||||
from_rev = from && page.wiki_revisions.find(from)
|
||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
|
||||
return render_unprocessable_entity('差分を表示できない版です.')
|
||||
tag_group = TagGroup.find_by(id:)
|
||||
if tag_group
|
||||
from_rev = from && group_version(tag_group, from)
|
||||
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
|
||||
return head :not_found if (from && !from_rev) || !to_rev
|
||||
|
||||
from_body = from_rev && render_group_version(from_rev)
|
||||
to_body = render_group_version(to_rev)
|
||||
resource = { 'id' => tag_group.id,
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||
'deprecated_at' => nil }
|
||||
else
|
||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
||||
from_rev = from && page.wiki_revisions.find(from)
|
||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
|
||||
return render_unprocessable_entity('差分を表示できない版です.')
|
||||
end
|
||||
from_body = from_rev&.body
|
||||
to_body = to_rev.body
|
||||
resource = WikiPageRepr.base(page)
|
||||
end
|
||||
resource = WikiPageRepr.base(page)
|
||||
end
|
||||
|
||||
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
|
||||
diff_json = diffs.map { |change|
|
||||
case change.action
|
||||
when ?=
|
||||
{ type: 'context', content: change.old_element }
|
||||
when ?!
|
||||
[{ type: 'removed', content: change.old_element },
|
||||
{ type: 'added', content: change.new_element }]
|
||||
when ?+
|
||||
{ type: 'added', content: change.new_element }
|
||||
when ?-
|
||||
{ type: 'removed', content: change.old_element }
|
||||
end
|
||||
}.flatten.compact
|
||||
from_body ||= from_rev&.body
|
||||
to_body ||= to_rev.body
|
||||
diff_json = wiki_diff(from_body, to_body)
|
||||
|
||||
render json: { wiki_page_id: resource.fetch('id'),
|
||||
title: resource.fetch('title'),
|
||||
@@ -109,6 +134,8 @@ class WikiPagesController < ApplicationController
|
||||
older_revision_id: from_rev&.id,
|
||||
newer_revision_id: to_rev.id,
|
||||
diff: diff_json }
|
||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -122,6 +149,17 @@ class WikiPagesController < ApplicationController
|
||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||
parsed = TagGroupMarkdownParser.parse(body:, group: nil)
|
||||
group = TagGroupOperation.create!(name:, **parsed, actor: current_user,
|
||||
require_compatibility: true)
|
||||
version = current_group_version(group)
|
||||
return render_group_representation(TagGroupRepr.one(group), version,
|
||||
body: parsed.fetch(:compatibility_body),
|
||||
status: :created)
|
||||
end
|
||||
|
||||
page = if title.include?(':')
|
||||
ApplicationRecord.transaction do
|
||||
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
|
||||
@@ -146,6 +184,8 @@ class WikiPagesController < ApplicationController
|
||||
render_validation_error e.record
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
render_record_not_unique
|
||||
rescue TagGroupMarkdownParser::InvalidDocument => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -158,7 +198,26 @@ class WikiPagesController < ApplicationController
|
||||
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
|
||||
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
|
||||
|
||||
page = WikiPage.find(params[:id])
|
||||
group = TagGroup.find_by(id: params[:id])
|
||||
if title.start_with?(TagGroupRepr::PREFIX)
|
||||
return head :not_found unless group
|
||||
|
||||
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||
parsed = TagGroupMarkdownParser.parse(body:, group:)
|
||||
TagGroupOperation.update!(group:, name:, **parsed, actor: current_user,
|
||||
require_compatibility: true)
|
||||
return head :ok
|
||||
end
|
||||
|
||||
page = WikiPage.find_by(id: params[:id])
|
||||
if legacy_group_page?(page)
|
||||
return render_unprocessable_entity field: :title
|
||||
end
|
||||
if group && !legacy_update_identified?(page, title, group)
|
||||
return render_unprocessable_entity field: :title
|
||||
end
|
||||
return head :not_found unless page
|
||||
|
||||
base_revision_id = params[:base_revision_id].presence
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@@ -175,11 +234,17 @@ class WikiPagesController < ApplicationController
|
||||
page.tag_name.update!(name: title) if title != old_title
|
||||
|
||||
message = params[:message].presence
|
||||
Wiki::Commit.content!(page:,
|
||||
body:,
|
||||
created_user: current_user,
|
||||
message:,
|
||||
base_revision_id:)
|
||||
description = TagDescription.find_by(id: page.id)
|
||||
description_page = description && (
|
||||
tag&.id == description.tag_id ||
|
||||
(tag.nil? && !old_title.include?(':')))
|
||||
if description && !description_page
|
||||
Wiki::Commit.legacy_content!(page:, body:, created_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
else
|
||||
Wiki::Commit.content!(page:, body:, created_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
end
|
||||
|
||||
if tag && title != old_title
|
||||
tag.reload
|
||||
@@ -188,6 +253,12 @@ class WikiPagesController < ApplicationController
|
||||
end
|
||||
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_validation_error e.record
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
render_record_not_unique
|
||||
rescue TagGroupMarkdownParser::InvalidDocument => error
|
||||
render_unprocessable_entity(error.message)
|
||||
end
|
||||
|
||||
def changes
|
||||
@@ -197,6 +268,11 @@ class WikiPagesController < ApplicationController
|
||||
return render json: description_changes(TagDescription.where(id: description.id))
|
||||
end
|
||||
|
||||
tag_group = id && TagGroup.find_by(id:)
|
||||
if tag_group
|
||||
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
||||
end
|
||||
|
||||
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
|
||||
revisions = WikiRevision.joins(wiki_page: :tag_name)
|
||||
.where(wiki_page_id: pages.select(:id))
|
||||
@@ -215,14 +291,122 @@ class WikiPagesController < ApplicationController
|
||||
timestamp: rev.created_at }
|
||||
}
|
||||
unless id
|
||||
json.concat(tag_group_changes(TagGroupVersion.all))
|
||||
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
||||
json.sort_by! { |row| -row[:revision_id] }
|
||||
json = json.each_with_index.sort_by do |row, index|
|
||||
[-row.fetch(:timestamp).to_f, index]
|
||||
end.map(&:first)
|
||||
end
|
||||
render json: json.first(200)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def legacy_group_page? page
|
||||
return false unless page
|
||||
return true if page.title.start_with?(TagGroupRepr::PREFIX)
|
||||
|
||||
prefix = ApplicationRecord.sanitize_sql_like(TagGroupRepr::PREFIX)
|
||||
page.wiki_versions.where('title LIKE ?', "#{ prefix }%").exists?
|
||||
end
|
||||
|
||||
def legacy_update_identified? page, title, group
|
||||
return false unless page
|
||||
return true if page.title == title
|
||||
return false if title == group.name
|
||||
|
||||
namespace = page.title.split(':', 2)
|
||||
return true if namespace.size == 2 && title.start_with?("#{ namespace.first }:")
|
||||
|
||||
base = params[:base_revision_id].presence
|
||||
base && page.wiki_revisions.exists?(id: base)
|
||||
end
|
||||
|
||||
def render_tag_group group
|
||||
version = if params[:version].present?
|
||||
group_version(group, params[:version])
|
||||
else
|
||||
current_group_version(group)
|
||||
end
|
||||
return head :not_found unless version
|
||||
|
||||
representation = if params[:version].present?
|
||||
TagGroupRepr.historical(version)
|
||||
else
|
||||
TagGroupRepr.one(group)
|
||||
end
|
||||
render_group_representation(representation, version)
|
||||
end
|
||||
|
||||
def render_group_version version
|
||||
representation = TagGroupRepr.historical(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,
|
||||
'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_or_404 page
|
||||
return head :not_found unless page
|
||||
|
||||
@@ -271,7 +455,10 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
def unmigrated_wiki_pages
|
||||
descriptions = TagDescription.where(language_code: 'ja')
|
||||
WikiPage.joins(:tag_name).where.not(id: descriptions.select(:id))
|
||||
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
|
||||
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
||||
descriptions.select(:tag_id))
|
||||
end
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
class TagGroup < ApplicationRecord
|
||||
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', dependent: :delete_all
|
||||
|
||||
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
|
||||
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class TagGroupGroup < ApplicationRecord
|
||||
self.primary_key = [:tag_group_id, :child_tag_group_id]
|
||||
|
||||
belongs_to :tag_group
|
||||
belongs_to :child_tag_group, class_name: 'TagGroup'
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class TagGroupPlacement < ApplicationRecord
|
||||
self.primary_key = [:tag_group_id, :position]
|
||||
|
||||
belongs_to :tag_group
|
||||
belongs_to :tag, optional: true
|
||||
belongs_to :child_tag_group, class_name: 'TagGroup', optional: true
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class TagGroupTag < ApplicationRecord
|
||||
self.primary_key = [:tag_group_id, :tag_id]
|
||||
|
||||
belongs_to :tag_group
|
||||
belongs_to :tag
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class TagGroupVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
belongs_to :tag_group
|
||||
|
||||
scope :ordered, -> { order(:version_no) }
|
||||
end
|
||||
@@ -0,0 +1,194 @@
|
||||
module TagGroupRepr
|
||||
PREFIX = 'タググループ:'
|
||||
|
||||
module_function
|
||||
|
||||
def many 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) +
|
||||
group_members.values.flatten.map(&:child_tag_group_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: 'ja', 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 group
|
||||
many([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 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(member, tag_versions, group_versions)
|
||||
end
|
||||
historical_placements = placements.map do |node|
|
||||
historical_placement(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.fetch(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.fetch(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 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' => japanese_version_name(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(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' => japanese_version_name(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(child, tag_versions, group_versions,
|
||||
tag_version_numbers, group_version_numbers)
|
||||
end)
|
||||
end
|
||||
|
||||
def japanese_version_name version
|
||||
version.names.fetch('ja').fetch('name')
|
||||
end
|
||||
end
|
||||
@@ -25,6 +25,10 @@ module WikiPageRepr
|
||||
'deprecated_at' => tag.deprecated_at }
|
||||
end
|
||||
|
||||
def tag_group tag_group
|
||||
TagGroupRepr.tag_group(tag_group)
|
||||
end
|
||||
|
||||
def many wiki_pages
|
||||
wiki_pages.map { |p| base(p) }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
require 'kramdown'
|
||||
|
||||
class TagGroupMarkdownParser
|
||||
PREFIX = TagGroupRepr::PREFIX
|
||||
|
||||
class InvalidDocument < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.parse(body:, group: nil)
|
||||
new(body:, group:).parse
|
||||
end
|
||||
|
||||
def initialize(body:, group:)
|
||||
@body = body.to_s.gsub(/\r\n?/, "\n")
|
||||
@group = group
|
||||
end
|
||||
|
||||
def parse
|
||||
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(_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(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 label.start_with?(PREFIX)
|
||||
group_name = label.delete_prefix(PREFIX).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(label) }
|
||||
end
|
||||
|
||||
node['children'] = nested.flat_map do |list|
|
||||
invalid!('未対応の入れ子リストです.') unless list.type == :ul
|
||||
list.children.map { parse_list_item(_1) }
|
||||
end
|
||||
node
|
||||
end
|
||||
|
||||
def resolve_tag(name)
|
||||
names = TagName.where(language_code: 'ja', name:).to_a
|
||||
tag_ids = names.filter_map(&:tag_id).uniq
|
||||
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
|
||||
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
|
||||
|
||||
tag_ids.first
|
||||
end
|
||||
|
||||
def build_members(nodes)
|
||||
references = []
|
||||
visit = lambda do |items|
|
||||
items.each do |node|
|
||||
references << node.slice('tag_id', 'tag_group_id') if
|
||||
node.key?('tag_id') || node.key?('tag_group_id')
|
||||
visit.call(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
visit.call(nodes)
|
||||
references.uniq
|
||||
end
|
||||
|
||||
def inline_text(node)
|
||||
if node.attr.any?
|
||||
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
|
||||
case node.type
|
||||
when :text
|
||||
node.value.to_s
|
||||
when :p, :header
|
||||
node.children.map { inline_text(_1) }.join
|
||||
else
|
||||
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
end
|
||||
|
||||
def inline_markdown(node)
|
||||
case node.type
|
||||
when :text
|
||||
node.value.to_s
|
||||
when :codespan
|
||||
text = node.value.to_s
|
||||
longest = text.scan(/`+/).map(&:length).max || 0
|
||||
delimiter = '`' * (longest + 1)
|
||||
padded = text.start_with?('`', ' ') || text.end_with?('`', ' ')
|
||||
content = padded ? " #{ text } " : text
|
||||
"#{ delimiter }#{ content }#{ delimiter }"
|
||||
when :softbreak
|
||||
"\n"
|
||||
when :br
|
||||
" \n"
|
||||
when :p
|
||||
node.children.map { inline_markdown(_1) }.join
|
||||
when :a
|
||||
href = node.attr.fetch('href')
|
||||
label = node.children.map { inline_markdown(_1) }.join
|
||||
title = node.attr['title']&.gsub('"', '\\"')
|
||||
suffix = title ? " \"#{ title }\"" : ''
|
||||
"[#{ label }](#{ href }#{ suffix })"
|
||||
when :em
|
||||
"*#{ node.children.map { inline_markdown(_1) }.join }*"
|
||||
when :strong
|
||||
"**#{ node.children.map { inline_markdown(_1) }.join }**"
|
||||
else
|
||||
invalid!("未対応の説明文構文 #{ node.type.inspect } です.")
|
||||
end
|
||||
end
|
||||
|
||||
def invalid! message
|
||||
raise InvalidDocument, message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
|
||||
sections = placements.select { _1.key?('section_name') }
|
||||
if sections.any? && sections.size != placements.size
|
||||
raise UnsupportedStructure,
|
||||
'最上位の見出し Section と Placement の混在は Markdown にできません.'
|
||||
end
|
||||
|
||||
if placements.all? { _1.key?('section_name') }
|
||||
blocks.concat(placements.map { render_heading(_1, 2) })
|
||||
else
|
||||
blocks << render_list(placements, 0)
|
||||
end
|
||||
|
||||
blocks.compact.reject(&:empty?).join("\n").sub(/\n+\z/, '')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_membership! placements
|
||||
placed = placement_members(placements).uniq.sort
|
||||
members = @group.fetch('members').map { |member|
|
||||
if member['tag']
|
||||
['tag', member.fetch('tag').fetch('id')]
|
||||
else
|
||||
['tag_group', member.fetch('tag_group').fetch('id')]
|
||||
end
|
||||
}.uniq.sort
|
||||
missing = members - placed
|
||||
if missing.any?
|
||||
raise UnsupportedStructure, '掲載位置のない membership は Markdown にできません.'
|
||||
end
|
||||
end
|
||||
|
||||
def placement_members nodes
|
||||
nodes.flat_map do |node|
|
||||
own = if node['tag']
|
||||
[['tag', node.fetch('tag').fetch('id')]]
|
||||
elsif node['tag_group']
|
||||
[['tag_group', node.fetch('tag_group').fetch('id')]]
|
||||
else
|
||||
[]
|
||||
end
|
||||
own + placement_members(node.fetch('children'))
|
||||
end
|
||||
end
|
||||
|
||||
def render_heading section, level
|
||||
header = "#{ '#' * level } #{ clean_text(section.fetch('section_name')) }"
|
||||
blocks = [header]
|
||||
blocks << clean_text(section['description']) if section['description'].present?
|
||||
children = section.fetch('children')
|
||||
section_indexes = children.each_index.select { |index| children[index].key?('section_name') }
|
||||
if section_indexes.any?
|
||||
first_section = section_indexes.first
|
||||
if children[first_section..].any? { !_1.key?('section_name') }
|
||||
raise UnsupportedStructure,
|
||||
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
|
||||
end
|
||||
list_nodes = children[0...first_section]
|
||||
blocks << render_list(list_nodes, 0) if list_nodes.any?
|
||||
blocks.concat(children[first_section..].map { render_heading(_1, level + 1) })
|
||||
elsif children.any?
|
||||
blocks << render_list(children, 0)
|
||||
end
|
||||
blocks.join("\n")
|
||||
end
|
||||
|
||||
def render_list nodes, depth
|
||||
nodes.map do |node|
|
||||
indent = ' ' * depth
|
||||
if node.key?('section_name')
|
||||
raise UnsupportedStructure,
|
||||
'リスト内 Section の説明文は Markdown にできません.' if node['description'].present?
|
||||
|
||||
children = node.fetch('children')
|
||||
head = "#{ indent }- #{ clean_text(node.fetch('section_name')) }:"
|
||||
tail = render_list(children, depth + 1) if children.any?
|
||||
[head, tail].compact.join("\n")
|
||||
else
|
||||
label = if node['tag']
|
||||
node.fetch('tag').fetch('name')
|
||||
else
|
||||
"#{ TagGroupRepr::PREFIX }#{ node.fetch('tag_group').fetch('name') }"
|
||||
end
|
||||
raise UnsupportedStructure, '名称のない Placement は Markdown にできません.' if label.blank?
|
||||
|
||||
head = "#{ indent }- #{ clean_text(label) }"
|
||||
children = node.fetch('children')
|
||||
tail = render_list(children, depth + 1) if children.any?
|
||||
[head, tail].compact.join("\n")
|
||||
end
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def clean_text value
|
||||
value.to_s.gsub(/\r\n?/, "\n").lines.map(&:rstrip).join("\n").strip
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,410 @@
|
||||
class TagGroupOperation
|
||||
MAX_PLACEMENT_DEPTH = 5
|
||||
PREFIX = TagGroupRepr::PREFIX
|
||||
|
||||
def self.create!(name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false, compatibility_body: nil)
|
||||
group, sync_issue = ApplicationRecord.transaction do
|
||||
group = TagGroup.new(version_no: 1, created_by_user: actor,
|
||||
updated_by_user: actor)
|
||||
attributes = prepare!(group:, name:, description:, members:, placements:)
|
||||
group.assign_attributes(name: attributes.fetch(:name),
|
||||
description: attributes.fetch(:description))
|
||||
group.save!
|
||||
persist_structure!(group:, attributes:, actor:)
|
||||
persist_version!(group:, attributes:, actor:, event_type: 'create')
|
||||
issue = sync_compatibility!(group:, actor:, old_name: nil,
|
||||
require_compatibility:, compatibility_body:)
|
||||
[group, issue]
|
||||
end
|
||||
report_sync_issue(group, actor, sync_issue)
|
||||
group
|
||||
end
|
||||
|
||||
def self.update!(group:, name:, description:, members:, placements:, actor:,
|
||||
require_compatibility: false, compatibility_body: nil)
|
||||
group, sync_issue = ApplicationRecord.transaction do
|
||||
group.lock!
|
||||
old_name = group.name
|
||||
attributes = prepare!(group:, name:, description:, members:, placements:)
|
||||
next [group, nil] if unchanged?(group, attributes)
|
||||
|
||||
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:)
|
||||
persist_version!(group:, attributes:, actor:, event_type: 'update')
|
||||
issue = sync_compatibility!(group:, actor:, old_name:,
|
||||
require_compatibility:, compatibility_body:)
|
||||
[group, issue]
|
||||
end
|
||||
report_sync_issue(group, actor, sync_issue)
|
||||
group
|
||||
end
|
||||
|
||||
def self.prepare!(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!(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!(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: 'ja', primary_flg: true)
|
||||
.pluck(:tag_id).uniq
|
||||
|
||||
members.each do |member|
|
||||
if member.key?('tag_id')
|
||||
id = member.fetch('tag_id')
|
||||
invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") 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.sync_compatibility!(group:, actor:, old_name:,
|
||||
require_compatibility:, compatibility_body:)
|
||||
representation = TagGroupRepr.one(group)
|
||||
body = begin
|
||||
TagGroupMarkdownRenderer.render(representation)
|
||||
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||
if compatibility_body.present?
|
||||
compatibility_body
|
||||
elsif require_compatibility
|
||||
invalid!(group, :base, error.message)
|
||||
else
|
||||
return error.message
|
||||
end
|
||||
end
|
||||
invalid!(group, :base, '本文は必須です.') if body.blank?
|
||||
|
||||
title = "#{ PREFIX }#{ group.name }"
|
||||
old_title = old_name && "#{ PREFIX }#{ old_name }"
|
||||
page = if old_title
|
||||
WikiPage.joins(:tag_name).find_by(tag_names: { name: old_title })
|
||||
else
|
||||
nil
|
||||
end
|
||||
page ||= WikiPage.joins(:tag_name).find_by(tag_names: { name: title })
|
||||
|
||||
if page
|
||||
tag_name = page.tag_name
|
||||
tag_name.update!(name: title) if tag_name.name != title
|
||||
Wiki::Commit.legacy_content!(page:, body:, created_user: actor, message: nil,
|
||||
base_revision_id: nil)
|
||||
else
|
||||
tag_name = TagName.find_or_initialize_by(language_code: 'ja', name: title)
|
||||
tag_name.script_code = 'Jpan'
|
||||
tag_name.primary_flg = true
|
||||
tag_name.auto_generated = false
|
||||
tag_name.save!
|
||||
Wiki::Commit.create_legacy_content!(tag_name:, body:, created_by_user: actor)
|
||||
end
|
||||
nil
|
||||
end
|
||||
private_class_method :sync_compatibility!
|
||||
|
||||
def self.report_sync_issue(group, actor, reason)
|
||||
return unless reason
|
||||
|
||||
payload = { status: 'unsupported_structure', tag_group_id: group.id,
|
||||
version_no: group.version_no, user_id: actor&.id, reason: }
|
||||
Rails.logger.warn("tag_group_wiki_sync_skipped #{ payload.to_json }")
|
||||
end
|
||||
private_class_method :report_sync_issue
|
||||
|
||||
def self.placement_references(nodes)
|
||||
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
|
||||
新しいイシューから参照
ユーザーをブロックする