タグ・グループを Wiki から分離 (#425) (#429)

Reviewed-on: #429
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #429 でマージされました。
このコミットが含まれているのは:
2026-10-11 03:39:47 +09:00
committed by みてるぞ
コミット 534a66f90d
25個のファイルの変更、3955行の追加、41行の削除
+177
ファイルの表示
@@ -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
+118
ファイルの表示
@@ -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
+410
ファイルの表示
@@ -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