コミットを比較
3
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
ef229ed49b | ||
|
|
68b38182b4 | ||
|
|
6ce7990178 |
@@ -1,149 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
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
|
|
||||||
生成ファイル
+1
-89
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) 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,80 +483,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) 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
|
||||||
@@ -897,20 +823,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) 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"
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする