コミットを比較
2
コミット
main
..
feature/425
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
343f33e1f9 | ||
|
|
9ca713d6e4 |
@@ -0,0 +1,149 @@
|
|||||||
|
class CreateTagGroups < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
# 翻訳系は上流の Translation リソースが未完のため保留
|
||||||
|
|
||||||
|
create_table :tag_groups do |t|
|
||||||
|
t.integer :version_no, null: false
|
||||||
|
t.string :name, limit: 255, null: false
|
||||||
|
t.text :description
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.references :updated_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.timestamps
|
||||||
|
|
||||||
|
t.check_constraint 'version_no > 0', name: 'chk_tag_groups_version_no_positive'
|
||||||
|
t.index :name, unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :tag_group_tags, primary_key: [:tag_group_id, :tag_id] do |t|
|
||||||
|
t.references :tag_group, null: false, foreign_key: true, index: false
|
||||||
|
t.references :tag, null: false, foreign_key: true
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :tag_group_groups, primary_key: [:tag_group_id, :child_tag_group_id] do |t|
|
||||||
|
t.references :tag_group, null: false, foreign_key: true, index: false
|
||||||
|
t.references :child_tag_group,
|
||||||
|
null: false, foreign_key: { to_table: :tag_groups }
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
|
||||||
|
t.check_constraint 'tag_group_id <> child_tag_group_id',
|
||||||
|
name: 'chk_tag_group_groups_tag_group_id_not_child_tag_group_id'
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :tag_group_placements, primary_key: [:tag_group_id, :position] do |t|
|
||||||
|
t.references :tag_group, null: false, foreign_key: true, index: false
|
||||||
|
t.integer :position, null: false
|
||||||
|
t.integer :parent_position
|
||||||
|
t.string :section_name, limit: 255
|
||||||
|
t.text :description
|
||||||
|
t.references :tag
|
||||||
|
t.references :child_tag_group
|
||||||
|
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.references :updated_by_user, foreign_key: { to_table: :users }
|
||||||
|
t.timestamps
|
||||||
|
|
||||||
|
t.foreign_key :tag_group_placements,
|
||||||
|
column: [:tag_group_id, :parent_position],
|
||||||
|
primary_key: [:tag_group_id, :position]
|
||||||
|
|
||||||
|
t.foreign_key :tag_group_tags,
|
||||||
|
column: [:tag_group_id, :tag_id],
|
||||||
|
primary_key: [:tag_group_id, :tag_id]
|
||||||
|
|
||||||
|
t.foreign_key :tag_group_groups,
|
||||||
|
column: [:tag_group_id, :child_tag_group_id],
|
||||||
|
primary_key: [:tag_group_id, :child_tag_group_id]
|
||||||
|
|
||||||
|
t.check_constraint 'position > parent_position',
|
||||||
|
name: 'chk_tag_group_placements_position_gt_parent_position'
|
||||||
|
|
||||||
|
t.check_constraint ('((section_name IS NOT NULL AND tag_id IS NULL AND child_tag_group_id IS NULL) OR ' +
|
||||||
|
'(section_name IS NULL AND tag_id IS NOT NULL AND child_tag_group_id IS NULL) OR ' +
|
||||||
|
'(section_name IS NULL AND tag_id IS NULL AND child_tag_group_id IS NOT NULL)) AND ' +
|
||||||
|
'(section_name IS NOT NULL OR description IS NULL)'),
|
||||||
|
name: 'chk_tag_group_placements_references_exclusive'
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table :tag_group_versions do |t|
|
||||||
|
t.references :tag_group, null: false
|
||||||
|
t.integer :version_no, null: false
|
||||||
|
t.string :event_type, null: false
|
||||||
|
t.string :name, limit: 255, null: false
|
||||||
|
t.text :description
|
||||||
|
t.json :members, null: false
|
||||||
|
t.json :placements, null: false
|
||||||
|
t.references :created_by_user
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
|
||||||
|
t.check_constraint 'version_no > 0',
|
||||||
|
name: 'chk_tag_group_versions_version_no_positive'
|
||||||
|
t.index [:tag_group_id, :version_no], unique: true
|
||||||
|
|
||||||
|
add_members_constraint!(t)
|
||||||
|
add_placements_constraint!(t)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def add_members_constraint! t
|
||||||
|
schema = { type: 'array',
|
||||||
|
uniqueItems: true,
|
||||||
|
items: member_schema }
|
||||||
|
|
||||||
|
quoted_schema = connection.quote(JSON.generate(schema))
|
||||||
|
|
||||||
|
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, members)",
|
||||||
|
name: 'chk_tag_group_versions_members_schema'
|
||||||
|
end
|
||||||
|
|
||||||
|
def member_schema
|
||||||
|
{ oneOf: [tag_member_schema, group_member_schema] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_member_schema
|
||||||
|
{ type: 'object',
|
||||||
|
required: ['tag_id', 'version_no'],
|
||||||
|
properties: { tag_id: { type: 'integer', minimum: 1 },
|
||||||
|
version_no: { type: 'integer', minimum: 1 } },
|
||||||
|
additionalProperties: false }
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_member_schema
|
||||||
|
{ type: 'object',
|
||||||
|
required: ['tag_group_id', 'version_no'],
|
||||||
|
properties: { tag_group_id: { type: 'integer', minimum: 1 },
|
||||||
|
version_no: { type: 'integer', minimum: 1 } },
|
||||||
|
additionalProperties: false }
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_placements_constraint! t
|
||||||
|
schema = { type: 'array', items: placement_schema(5) }
|
||||||
|
|
||||||
|
quoted_schema = connection.quote(JSON.generate(schema))
|
||||||
|
|
||||||
|
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, placements)",
|
||||||
|
name: 'chk_tag_group_versions_placements_schema'
|
||||||
|
end
|
||||||
|
|
||||||
|
def placement_schema rest
|
||||||
|
{ type: 'object',
|
||||||
|
required: ['children'],
|
||||||
|
properties: { section_name: { type: 'string' },
|
||||||
|
description: { type: ['string', 'null'] },
|
||||||
|
tag_id: { type: 'integer', minimum: 1 },
|
||||||
|
tag_group_id: { type: 'integer', minimum: 1 },
|
||||||
|
children: if rest > 1
|
||||||
|
{ type: 'array', items: placement_schema(rest - 1) }
|
||||||
|
else
|
||||||
|
{ type: 'array', maxItems: 0 }
|
||||||
|
end },
|
||||||
|
oneOf: [{ required: ['section_name'] },
|
||||||
|
{ required: ['tag_id'] },
|
||||||
|
{ required: ['tag_group_id'] }],
|
||||||
|
dependencies: { description: ['section_name'] },
|
||||||
|
additionalProperties: false }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
class MigrateTagGroupsFromWikiPages < ActiveRecord::Migration[8.0]
|
||||||
|
PREFIX = 'タググループ:'
|
||||||
|
|
||||||
|
def up
|
||||||
|
require 'json'
|
||||||
|
require 'time'
|
||||||
|
require 'kramdown'
|
||||||
|
require 'kramdown-parser-gfm'
|
||||||
|
|
||||||
|
assert_empty_destination!
|
||||||
|
|
||||||
|
pages = connection.select_all(<<~SQL).to_a
|
||||||
|
SELECT
|
||||||
|
wp.id
|
||||||
|
, wp.created_user_id
|
||||||
|
, wp.updated_user_id
|
||||||
|
, wp.created_at
|
||||||
|
, wp.updated_at
|
||||||
|
, wp.discarded_at
|
||||||
|
, tn.name
|
||||||
|
FROM
|
||||||
|
wiki_pages wp
|
||||||
|
INNER JOIN
|
||||||
|
tag_names tn
|
||||||
|
ON
|
||||||
|
tn.id = wp.tag_name_id
|
||||||
|
WHERE
|
||||||
|
tn.language_code = 'ja'
|
||||||
|
AND tn.name LIKE #{ quote("#{ PREFIX }%") }
|
||||||
|
ORDER BY
|
||||||
|
wp.id
|
||||||
|
SQL
|
||||||
|
|
||||||
|
return if pages.empty?
|
||||||
|
|
||||||
|
pages.each do |page|
|
||||||
|
if page['discarded_at']
|
||||||
|
say "Wiki ##{ page.fetch('id') }: 破棄済みですが,Revision の移行を継続します."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
tag_ids = load_tag_ids
|
||||||
|
page_ids = pages.map { |page| page.fetch('id').to_i }
|
||||||
|
|
||||||
|
revisions = connection.select_all(<<~SQL).to_a
|
||||||
|
SELECT
|
||||||
|
wr.id
|
||||||
|
, wr.wiki_page_id
|
||||||
|
, wr.kind
|
||||||
|
, wr.redirect_page_id
|
||||||
|
, wr.created_user_id
|
||||||
|
, wr.created_at
|
||||||
|
FROM
|
||||||
|
wiki_revisions wr
|
||||||
|
WHERE
|
||||||
|
wr.wiki_page_id IN (#{ page_ids.join(', ') })
|
||||||
|
ORDER BY
|
||||||
|
wr.wiki_page_id
|
||||||
|
, wr.id
|
||||||
|
SQL
|
||||||
|
|
||||||
|
revisions_by_page = revisions.group_by { |row| row.fetch('wiki_page_id').to_i }
|
||||||
|
|
||||||
|
pages = pages.select do |page|
|
||||||
|
page_id = page.fetch('id').to_i
|
||||||
|
if revisions_by_page.fetch(page_id, []).empty?
|
||||||
|
# tag_groups.version_no > 0 のため,Revision 0 件の Wiki は作成しない.
|
||||||
|
say "Wiki ##{ page_id }: Revision が 0 件のため移行対象から除外します."
|
||||||
|
false
|
||||||
|
else
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return if pages.empty?
|
||||||
|
|
||||||
|
group_ids = pages.to_h { |page| [page.fetch('name'), page.fetch('id').to_i] }
|
||||||
|
revision_history = pages.to_h do |page|
|
||||||
|
page_id = page.fetch('id').to_i
|
||||||
|
history = revisions_by_page.fetch(page_id).each_with_index.map do |revision, index|
|
||||||
|
{ 'id' => revision.fetch('id').to_i,
|
||||||
|
'version_no' => index + 1,
|
||||||
|
'created_at' => revision.fetch('created_at') }
|
||||||
|
end
|
||||||
|
[page_id, history]
|
||||||
|
end
|
||||||
|
|
||||||
|
tag_history = connection.select_all(<<~SQL).to_a.group_by { |row| row.fetch('tag_id').to_i }
|
||||||
|
SELECT
|
||||||
|
tag_id
|
||||||
|
, version_no
|
||||||
|
, created_at
|
||||||
|
FROM
|
||||||
|
tag_versions
|
||||||
|
ORDER BY
|
||||||
|
tag_id
|
||||||
|
, created_at
|
||||||
|
, version_no
|
||||||
|
SQL
|
||||||
|
|
||||||
|
prepared = { }
|
||||||
|
pages.each do |page|
|
||||||
|
page_id = page.fetch('id').to_i
|
||||||
|
history = revisions_by_page.fetch(page_id)
|
||||||
|
|
||||||
|
prepared[page_id] = history.each_with_index.map do |revision, index|
|
||||||
|
context = "Wiki ##{ page_id } / Revision ##{ revision.fetch('id') }"
|
||||||
|
body = revision_body(revision.fetch('id').to_i)
|
||||||
|
|
||||||
|
if revision.fetch('kind').to_i == 1
|
||||||
|
destination = revision['redirect_page_id']
|
||||||
|
description = "旧 Wiki のリダイレクト先: Wiki ##{ destination }"
|
||||||
|
placements = []
|
||||||
|
members = []
|
||||||
|
say "#{ context }: リダイレクトを説明文として移行します."
|
||||||
|
else
|
||||||
|
begin
|
||||||
|
description, placements = parse_wiki(body, tag_ids, group_ids, context)
|
||||||
|
validate_tree!(placements, context)
|
||||||
|
members = build_members(placements, revision.fetch('created_at'),
|
||||||
|
tag_history, revision_history, context)
|
||||||
|
rescue StandardError => error
|
||||||
|
# 解析できない版も捨てず,当時の Markdown を説明文として保存する.
|
||||||
|
say "#{ context }: 構造化を保留し,本文を保存します (#{ error.message })."
|
||||||
|
description = body
|
||||||
|
placements = []
|
||||||
|
members = []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{ revision:, version_no: index + 1, description:, members:, placements:, body: }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# 自己包含は禁止されてゐるため,当該版は原文の保存を優先する.
|
||||||
|
prepared.each do |group_id, versions|
|
||||||
|
current = versions.last
|
||||||
|
next unless current.fetch(:members).any? { |member| member['tag_group_id'] == group_id }
|
||||||
|
|
||||||
|
say "Tag Group ##{ group_id }: 直接の自己参照を検出したため,現行構造化を保留します."
|
||||||
|
current[:description] = current.fetch(:body)
|
||||||
|
current[:members] = []
|
||||||
|
current[:placements] = []
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
validate_current_groups!(prepared)
|
||||||
|
rescue StandardError => error
|
||||||
|
say "Tag Group の包含関係に問題がありますが,移行を継続します (#{ error.message })."
|
||||||
|
end
|
||||||
|
|
||||||
|
# 移行先の書込みはすべて検証の後.DDL は含めない.
|
||||||
|
connection.transaction do
|
||||||
|
pages.each do |page|
|
||||||
|
page_id = page.fetch('id').to_i
|
||||||
|
current = prepared.fetch(page_id).last
|
||||||
|
|
||||||
|
insert_row('tag_groups', {
|
||||||
|
id: page_id,
|
||||||
|
# page.version_no ではなく,この Wiki に紐付く Revision の総数.
|
||||||
|
version_no: revisions_by_page.fetch(page_id).size,
|
||||||
|
name: page.fetch('name').delete_prefix(PREFIX),
|
||||||
|
description: current.fetch(:description),
|
||||||
|
created_by_user_id: page.fetch('created_user_id'),
|
||||||
|
updated_by_user_id: page.fetch('updated_user_id'),
|
||||||
|
created_at: page.fetch('created_at'),
|
||||||
|
updated_at: page.fetch('updated_at') })
|
||||||
|
end
|
||||||
|
|
||||||
|
pages.each do |page|
|
||||||
|
page_id = page.fetch('id').to_i
|
||||||
|
name = page.fetch('name').delete_prefix(PREFIX)
|
||||||
|
|
||||||
|
prepared.fetch(page_id).each do |item|
|
||||||
|
revision = item.fetch(:revision)
|
||||||
|
|
||||||
|
insert_row('tag_group_versions', {
|
||||||
|
id: revision.fetch('id'),
|
||||||
|
tag_group_id: page_id,
|
||||||
|
version_no: item.fetch(:version_no),
|
||||||
|
event_type: item.fetch(:version_no) == 1 ? 'create' : 'update',
|
||||||
|
name:,
|
||||||
|
description: item.fetch(:description),
|
||||||
|
members: JSON.generate(item.fetch(:members)),
|
||||||
|
placements: JSON.generate(item.fetch(:placements)),
|
||||||
|
created_by_user_id: revision.fetch('created_user_id'),
|
||||||
|
created_at: revision.fetch('created_at') })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
pages.each do |page|
|
||||||
|
page_id = page.fetch('id').to_i
|
||||||
|
current = prepared.fetch(page_id).last
|
||||||
|
revision = current.fetch(:revision)
|
||||||
|
|
||||||
|
current.fetch(:members).each do |member|
|
||||||
|
if member.key?('tag_id')
|
||||||
|
insert_row('tag_group_tags', {
|
||||||
|
tag_group_id: page_id,
|
||||||
|
tag_id: member.fetch('tag_id'),
|
||||||
|
created_by_user_id: revision.fetch('created_user_id'),
|
||||||
|
created_at: revision.fetch('created_at') })
|
||||||
|
else
|
||||||
|
insert_row('tag_group_groups', {
|
||||||
|
tag_group_id: page_id,
|
||||||
|
child_tag_group_id: member.fetch('tag_group_id'),
|
||||||
|
created_by_user_id: revision.fetch('created_user_id'),
|
||||||
|
created_at: revision.fetch('created_at') })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
pages.each do |page|
|
||||||
|
page_id = page.fetch('id').to_i
|
||||||
|
current = prepared.fetch(page_id).last
|
||||||
|
revision = current.fetch(:revision)
|
||||||
|
position = 0
|
||||||
|
|
||||||
|
append = -> nodes, parent_position do
|
||||||
|
nodes.each do |node|
|
||||||
|
position += 1
|
||||||
|
own_position = position
|
||||||
|
|
||||||
|
insert_row('tag_group_placements', {
|
||||||
|
tag_group_id: page_id,
|
||||||
|
position: own_position,
|
||||||
|
parent_position:,
|
||||||
|
section_name: node['section_name'],
|
||||||
|
description: node['description'],
|
||||||
|
tag_id: node['tag_id'],
|
||||||
|
child_tag_group_id: node['tag_group_id'],
|
||||||
|
created_by_user_id: revision.fetch('created_user_id'),
|
||||||
|
updated_by_user_id: revision.fetch('created_user_id'),
|
||||||
|
created_at: revision.fetch('created_at'),
|
||||||
|
updated_at: revision.fetch('created_at') })
|
||||||
|
|
||||||
|
append.(node.fetch('children'), own_position)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
append.(current.fetch(:placements), nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
raise ActiveRecord::IrreversibleMigration, '戻せません.'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def assert_empty_destination!
|
||||||
|
['tag_groups', 'tag_group_versions', 'tag_group_tags',
|
||||||
|
'tag_group_groups', 'tag_group_placements'].each do |table|
|
||||||
|
count = connection.select_value("SELECT COUNT(*) FROM #{ table }").to_i
|
||||||
|
raise "#{ table } が空ではありません." if count.positive?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# 解決は tag_names.name -> tag_names.tag_id のみ.
|
||||||
|
# tags.tag_name_id は読まない.
|
||||||
|
def load_tag_ids
|
||||||
|
rows = connection.select_all(<<~SQL).to_a
|
||||||
|
SELECT
|
||||||
|
name
|
||||||
|
, tag_id
|
||||||
|
FROM
|
||||||
|
tag_names
|
||||||
|
WHERE
|
||||||
|
language_code = 'ja'
|
||||||
|
AND tag_id IS NOT NULL
|
||||||
|
SQL
|
||||||
|
|
||||||
|
rows.each_with_object({ }) do |row, by_name|
|
||||||
|
name = row.fetch('name')
|
||||||
|
id = row.fetch('tag_id').to_i
|
||||||
|
by_name[name] ||= []
|
||||||
|
by_name[name] << id unless by_name[name].include?(id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def revision_body revision_id
|
||||||
|
connection.select_rows(<<~SQL).flatten.join("\n")
|
||||||
|
SELECT
|
||||||
|
wl.body
|
||||||
|
FROM
|
||||||
|
wiki_revision_lines wrl
|
||||||
|
INNER JOIN wiki_lines wl ON wl.id = wrl.wiki_line_id
|
||||||
|
WHERE
|
||||||
|
wrl.wiki_revision_id = #{ Integer(revision_id) }
|
||||||
|
ORDER BY
|
||||||
|
wrl.position
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_wiki markdown, tag_ids, group_ids, context
|
||||||
|
root = Kramdown::Document.new(markdown, input: 'GFM').root
|
||||||
|
placements = []
|
||||||
|
description_parts = []
|
||||||
|
headings = []
|
||||||
|
|
||||||
|
root.children.each_with_index do |node, index|
|
||||||
|
case node.type
|
||||||
|
when :blank
|
||||||
|
next
|
||||||
|
when :header
|
||||||
|
level = node.options.fetch(:level)
|
||||||
|
name = inline_text(node).strip
|
||||||
|
raise "#{ context }: 名前のない見出しがあります." if name.empty?
|
||||||
|
|
||||||
|
headings.pop while headings.any? && headings.last.first >= level
|
||||||
|
parent = headings.last&.last
|
||||||
|
section = { 'section_name' => name, 'description' => nil, 'children' => [] }
|
||||||
|
(parent ? parent.fetch('children') : placements) << section
|
||||||
|
headings << [level, section]
|
||||||
|
when :p
|
||||||
|
text = inline_markdown(node).strip
|
||||||
|
next if text.empty?
|
||||||
|
|
||||||
|
if headings.any?
|
||||||
|
heading = headings.last.last
|
||||||
|
heading['description'] = [heading['description'], text].compact.join("\n\n")
|
||||||
|
else
|
||||||
|
description_parts << text
|
||||||
|
end
|
||||||
|
when :ul, :ol
|
||||||
|
if headings.any? && see_also_heading?(headings.last.last.fetch('section_name'))
|
||||||
|
heading = headings.last.last
|
||||||
|
raw = original_block(markdown, root.children, index)
|
||||||
|
heading['description'] = [heading['description'], raw].compact.join("\n\n")
|
||||||
|
else
|
||||||
|
target = headings.any? ? headings.last.last.fetch('children') : placements
|
||||||
|
target.concat(node.children.map { |item| parse_list_item(item, tag_ids, group_ids, context) })
|
||||||
|
end
|
||||||
|
else
|
||||||
|
raise "#{ context }: 未対応の Markdown 構文 #{ node.type.inspect }"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
[description_parts.empty? ? nil : description_parts.join("\n\n"), placements]
|
||||||
|
end
|
||||||
|
|
||||||
|
def see_also_heading? name
|
||||||
|
['see also', '関連項目'].include?(name.strip.downcase.sub(/[::]\z/, ''))
|
||||||
|
end
|
||||||
|
|
||||||
|
def original_block markdown, siblings, index
|
||||||
|
current = siblings.fetch(index)
|
||||||
|
start_line = current.options[:location]
|
||||||
|
raise 'Markdown block の位置が不明です.' unless start_line
|
||||||
|
|
||||||
|
following = siblings[(index + 1)..].find do |node|
|
||||||
|
node.options[:location] && node.options[:location] > start_line
|
||||||
|
end
|
||||||
|
end_line = following ? following.options.fetch(:location) - 1 : markdown.lines.length
|
||||||
|
markdown.lines[(start_line - 1)...end_line].join.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_list_item item, tag_ids, group_ids, context
|
||||||
|
raise "#{ context }: リスト項目ではありません." if item.type != :li
|
||||||
|
|
||||||
|
nested, own = item.children.partition { |child| [:ul, :ol].include?(child.type) }
|
||||||
|
own.reject! { |child| child.type == :blank }
|
||||||
|
unless own.all? { |child| [:p, :text, :a, :codespan].include?(child.type) }
|
||||||
|
raise "#{ context }: 未対応のリスト項目があります."
|
||||||
|
end
|
||||||
|
|
||||||
|
label = own.map { |child| inline_text(child) }.join(' ').strip
|
||||||
|
raise "#{ context }: 空のリスト項目があります." if label.empty?
|
||||||
|
|
||||||
|
node =
|
||||||
|
if group_ids.key?(label)
|
||||||
|
{ 'tag_group_id' => group_ids.fetch(label) }
|
||||||
|
elsif tag_ids.key?(label)
|
||||||
|
ids = tag_ids.fetch(label)
|
||||||
|
unless ids.one?
|
||||||
|
raise "#{ context }: タグ名 #{ label.inspect } が複数の ID を指します:#{ ids.inspect }"
|
||||||
|
end
|
||||||
|
{ 'tag_id' => ids.first }
|
||||||
|
elsif label.end_with?(':', ':')
|
||||||
|
name = label.sub(/[::]\z/, '')
|
||||||
|
raise "#{ context }: 空の区分名があります." if name.empty?
|
||||||
|
if see_also_heading?(name) && nested.any?
|
||||||
|
raise "#{ context }: リスト内の See also は説明文への保管処理が未実装です."
|
||||||
|
end
|
||||||
|
{ 'section_name' => name, 'description' => nil }
|
||||||
|
else
|
||||||
|
raise "#{ context }: 解決不能なリスト項目 #{ label.inspect }"
|
||||||
|
end
|
||||||
|
|
||||||
|
node['children'] = nested.flat_map do |list|
|
||||||
|
list.children.map { |child| parse_list_item(child, tag_ids, group_ids, context) }
|
||||||
|
end
|
||||||
|
|
||||||
|
node
|
||||||
|
end
|
||||||
|
|
||||||
|
def inline_text node
|
||||||
|
case node.type
|
||||||
|
when :text, :codespan
|
||||||
|
node.value.to_s
|
||||||
|
when :softbreak, :br
|
||||||
|
' '
|
||||||
|
when :a, :p, :header, :em, :strong, :span, :li
|
||||||
|
node.children.map { |child| inline_text(child) }.join
|
||||||
|
else
|
||||||
|
raise "未対応のインライン構文 #{ node.type.inspect }"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def inline_markdown node
|
||||||
|
case node.type
|
||||||
|
when :text
|
||||||
|
node.value.to_s
|
||||||
|
when :codespan
|
||||||
|
"`#{ node.value }`"
|
||||||
|
when :softbreak
|
||||||
|
"\n"
|
||||||
|
when :br
|
||||||
|
" \n"
|
||||||
|
when :p
|
||||||
|
node.children.map { |child| inline_markdown(child) }.join
|
||||||
|
when :a
|
||||||
|
href = node.attr.fetch('href')
|
||||||
|
"[#{ node.children.map { |child| inline_markdown(child) }.join }](#{ href })"
|
||||||
|
when :em
|
||||||
|
"*#{ node.children.map { |child| inline_markdown(child) }.join }*"
|
||||||
|
when :strong
|
||||||
|
"**#{ node.children.map { |child| inline_markdown(child) }.join }**"
|
||||||
|
else
|
||||||
|
raise "未対応のインライン構文 #{ node.type.inspect }"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_tree! nodes, context, depth = 1
|
||||||
|
raise "#{ context }: 配置の深さが 5 を超えてゐます." if depth > 5 && nodes.any?
|
||||||
|
|
||||||
|
nodes.each do |node|
|
||||||
|
name = node['section_name']
|
||||||
|
if name && name.length > 255
|
||||||
|
raise "#{ context }: 区分名が 255 文字を超えてゐます."
|
||||||
|
end
|
||||||
|
validate_tree!(node.fetch('children'), context, depth + 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_members nodes, date, tag_history, group_history, context
|
||||||
|
ids_of_tags = []
|
||||||
|
ids_of_groups = []
|
||||||
|
|
||||||
|
visit = -> items do
|
||||||
|
items.each do |node|
|
||||||
|
ids_of_tags << node['tag_id'] if node.key?('tag_id')
|
||||||
|
ids_of_groups << node['tag_group_id'] if node.key?('tag_group_id')
|
||||||
|
visit.(node.fetch('children'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
visit.(nodes)
|
||||||
|
|
||||||
|
tags = ids_of_tags.uniq.map do |id|
|
||||||
|
{ 'tag_id' => id,
|
||||||
|
'version_no' => version_at(tag_history[id], date, context, "Tag ##{ id }") }
|
||||||
|
end
|
||||||
|
|
||||||
|
groups = ids_of_groups.uniq.map do |id|
|
||||||
|
{ 'tag_group_id' => id,
|
||||||
|
'version_no' => version_at(group_history[id], date, context, "TagGroup ##{ id }") }
|
||||||
|
end
|
||||||
|
|
||||||
|
tags + groups
|
||||||
|
end
|
||||||
|
|
||||||
|
def version_at history, date, context, target
|
||||||
|
raise "#{ context }: #{ target } の履歴が存在しません." unless history&.any?
|
||||||
|
|
||||||
|
timestamp = Time.parse(date.to_s)
|
||||||
|
candidates = history.select do |row|
|
||||||
|
Time.parse(row.fetch('created_at').to_s) <= timestamp
|
||||||
|
end
|
||||||
|
if candidates.empty?
|
||||||
|
raise "#{ context }: #{ target } は #{ date } 時点で存在しません."
|
||||||
|
end
|
||||||
|
|
||||||
|
candidates.max_by { |row|
|
||||||
|
[Time.parse(row.fetch('created_at').to_s), row.fetch('version_no').to_i]
|
||||||
|
}.fetch('version_no').to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_current_groups! prepared
|
||||||
|
edges = { }
|
||||||
|
prepared.each do |group_id, versions|
|
||||||
|
edges[group_id] = versions.last.fetch(:members)
|
||||||
|
.filter_map { |member| member['tag_group_id'] }
|
||||||
|
end
|
||||||
|
|
||||||
|
visiting = { }
|
||||||
|
visited = { }
|
||||||
|
visit = -> group_id do
|
||||||
|
raise "Tag Group の循環参照を検出しました:#{ group_id }" if visiting[group_id]
|
||||||
|
return if visited[group_id]
|
||||||
|
|
||||||
|
visiting[group_id] = true
|
||||||
|
edges.fetch(group_id, []).each { |child_id| visit.(child_id) }
|
||||||
|
visiting.delete(group_id)
|
||||||
|
visited[group_id] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
edges.keys.each { |group_id| visit.(group_id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def quote(value) = connection.quote(value)
|
||||||
|
|
||||||
|
def insert_row table, values
|
||||||
|
columns = values.keys.join(', ')
|
||||||
|
payload = values.values.map { |value| quote(value) }.join(', ')
|
||||||
|
execute "INSERT INTO #{ table } (#{ columns }) VALUES (#{ payload })"
|
||||||
|
end
|
||||||
|
end
|
||||||
生成ファイル
+89
-1
@@ -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_08_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
|
||||||
@@ -823,6 +897,20 @@ 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"
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする