このコミットが含まれているのは:
@@ -0,0 +1,13 @@
|
|||||||
|
class TagGroupsController < ApplicationController
|
||||||
|
def index
|
||||||
|
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
|
||||||
|
render json: TagGroupRepr.many(groups.to_a)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
|
||||||
|
return head :not_found unless group
|
||||||
|
|
||||||
|
render json: TagGroupRepr.one(group)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,26 +2,25 @@ class WikiPagesController < ApplicationController
|
|||||||
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
|
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
|
||||||
|
|
||||||
def index
|
def index
|
||||||
# 互換性のための化石リソース
|
|
||||||
# TODO: ヘルプ追加後にヘルプからも取得する作りに変更する.
|
|
||||||
|
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
descriptions =
|
descriptions =
|
||||||
TagDescription.where(language_code: 'ja')
|
TagDescription.where(language_code: 'ja')
|
||||||
.includes(tag: :tag_names)
|
.includes(tag: :tag_names)
|
||||||
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
|
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
|
||||||
|
|
||||||
|
tag_groups = TagGroup.all
|
||||||
if title.present?
|
if title.present?
|
||||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||||
descriptions = descriptions.joins(tag: :tag_names)
|
descriptions = descriptions.joins(tag: :tag_names)
|
||||||
.where(tag_names: { language_code: 'ja', primary_flg: true })
|
.where(tag_names: { language_code: 'ja', primary_flg: true })
|
||||||
.where('tag_names.name LIKE ?', pattern).limit(20)
|
.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||||
tag_groups = TagGroup.where("CONCAT('タググループ:', name) LIKE ?", pattern).limit(20)
|
tag_groups = tag_groups.where(
|
||||||
|
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
|
||||||
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
|
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||||
end
|
end
|
||||||
|
|
||||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
||||||
json.concat(tag_groups.map { |tag_gorup| WikiPageRepr.tag_group(tag_group) })
|
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||||
json.concat(WikiPageRepr.base(pages))
|
json.concat(WikiPageRepr.base(pages))
|
||||||
json = json.first(20) if title.present?
|
json = json.first(20) if title.present?
|
||||||
render json:
|
render json:
|
||||||
@@ -32,6 +31,9 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||||
|
|
||||||
|
tag_group = TagGroup.find_by(id: params[:id])
|
||||||
|
return render_tag_group tag_group if tag_group
|
||||||
|
|
||||||
page = WikiPage.joins(:tag_name)
|
page = WikiPage.joins(:tag_name)
|
||||||
.includes(tag_name: :tag)
|
.includes(tag_name: :tag)
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
@@ -42,6 +44,13 @@ class WikiPagesController < ApplicationController
|
|||||||
def show_by_title
|
def show_by_title
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
|
|
||||||
|
if title.start_with?(TagGroupRepr::PREFIX)
|
||||||
|
name = title.delete_prefix(TagGroupRepr::PREFIX)
|
||||||
|
tag_group = TagGroup.find_by(name:)
|
||||||
|
return render_tag_group tag_group if tag_group
|
||||||
|
return head :not_found
|
||||||
|
end
|
||||||
|
|
||||||
tag_description = tag_description_by_title(title)
|
tag_description = tag_description_by_title(title)
|
||||||
|
|
||||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||||
@@ -55,12 +64,17 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
def exists
|
def exists
|
||||||
return head :no_content if TagDescription.exists?(id: params[:id])
|
return head :no_content if TagDescription.exists?(id: params[:id])
|
||||||
|
return head :no_content if TagGroup.exists?(id: params[:id])
|
||||||
|
|
||||||
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
|
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def exists_by_title
|
def exists_by_title
|
||||||
title = params[:title].to_s.strip
|
title = params[:title].to_s.strip
|
||||||
|
if title.start_with?(TagGroupRepr::PREFIX)
|
||||||
|
exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX))
|
||||||
|
return head exists ? :no_content : :not_found
|
||||||
|
end
|
||||||
return head :no_content if tag_description_by_title(title)
|
return head :no_content if tag_description_by_title(title)
|
||||||
|
|
||||||
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
||||||
@@ -81,29 +95,33 @@ class WikiPagesController < ApplicationController
|
|||||||
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
|
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
|
||||||
resource = WikiPageRepr.tag_description(description)
|
resource = WikiPageRepr.tag_description(description)
|
||||||
else
|
else
|
||||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
tag_group = TagGroup.find_by(id:)
|
||||||
from_rev = from && page.wiki_revisions.find(from)
|
if tag_group
|
||||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
from_rev = from && group_version(tag_group, from)
|
||||||
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
|
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
|
||||||
return render_unprocessable_entity('差分を表示できない版です.')
|
return head :not_found if (from && !from_rev) || !to_rev
|
||||||
|
|
||||||
|
from_body = from_rev && render_group_version(from_rev)
|
||||||
|
to_body = render_group_version(to_rev)
|
||||||
|
resource = { 'id' => tag_group.id,
|
||||||
|
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||||
|
'deprecated_at' => nil }
|
||||||
|
else
|
||||||
|
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
||||||
|
from_rev = from && page.wiki_revisions.find(from)
|
||||||
|
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||||
|
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
|
||||||
|
return render_unprocessable_entity('差分を表示できない版です.')
|
||||||
|
end
|
||||||
|
from_body = from_rev&.body
|
||||||
|
to_body = to_rev.body
|
||||||
|
resource = WikiPageRepr.base(page)
|
||||||
end
|
end
|
||||||
resource = WikiPageRepr.base(page)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
|
from_body ||= from_rev&.body
|
||||||
diff_json = diffs.map { |change|
|
to_body ||= to_rev.body
|
||||||
case change.action
|
diff_json = wiki_diff(from_body, to_body)
|
||||||
when ?=
|
|
||||||
{ type: 'context', content: change.old_element }
|
|
||||||
when ?!
|
|
||||||
[{ type: 'removed', content: change.old_element },
|
|
||||||
{ type: 'added', content: change.new_element }]
|
|
||||||
when ?+
|
|
||||||
{ type: 'added', content: change.new_element }
|
|
||||||
when ?-
|
|
||||||
{ type: 'removed', content: change.old_element }
|
|
||||||
end
|
|
||||||
}.flatten.compact
|
|
||||||
|
|
||||||
render json: { wiki_page_id: resource.fetch('id'),
|
render json: { wiki_page_id: resource.fetch('id'),
|
||||||
title: resource.fetch('title'),
|
title: resource.fetch('title'),
|
||||||
@@ -111,6 +129,8 @@ class WikiPagesController < ApplicationController
|
|||||||
older_revision_id: from_rev&.id,
|
older_revision_id: from_rev&.id,
|
||||||
newer_revision_id: to_rev.id,
|
newer_revision_id: to_rev.id,
|
||||||
diff: diff_json }
|
diff: diff_json }
|
||||||
|
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||||
|
render_unprocessable_entity(error.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -199,6 +219,11 @@ class WikiPagesController < ApplicationController
|
|||||||
return render json: description_changes(TagDescription.where(id: description.id))
|
return render json: description_changes(TagDescription.where(id: description.id))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
tag_group = id && TagGroup.find_by(id:)
|
||||||
|
if tag_group
|
||||||
|
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
||||||
|
end
|
||||||
|
|
||||||
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
|
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
|
||||||
revisions = WikiRevision.joins(wiki_page: :tag_name)
|
revisions = WikiRevision.joins(wiki_page: :tag_name)
|
||||||
.where(wiki_page_id: pages.select(:id))
|
.where(wiki_page_id: pages.select(:id))
|
||||||
@@ -217,14 +242,102 @@ class WikiPagesController < ApplicationController
|
|||||||
timestamp: rev.created_at }
|
timestamp: rev.created_at }
|
||||||
}
|
}
|
||||||
unless id
|
unless id
|
||||||
|
json.concat(tag_group_changes(TagGroupVersion.all))
|
||||||
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
||||||
json.sort_by! { |row| -row[:revision_id] }
|
json = json.each_with_index.sort_by do |row, index|
|
||||||
|
[-row.fetch(:timestamp).to_f, index]
|
||||||
|
end.map(&:first)
|
||||||
end
|
end
|
||||||
render json: json.first(200)
|
render json: json.first(200)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def render_tag_group group
|
||||||
|
version = if params[:version].present?
|
||||||
|
group_version(group, params[:version])
|
||||||
|
else
|
||||||
|
current_group_version(group)
|
||||||
|
end
|
||||||
|
return head :not_found unless version
|
||||||
|
|
||||||
|
representation = if params[:version].present?
|
||||||
|
TagGroupRepr.historical(version)
|
||||||
|
else
|
||||||
|
TagGroupRepr.one(group)
|
||||||
|
end
|
||||||
|
render_group_representation(representation, version)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_group_version version
|
||||||
|
representation = TagGroupRepr.historical(version)
|
||||||
|
TagGroupMarkdownRenderer.render(representation)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_group_representation representation, version
|
||||||
|
body = TagGroupMarkdownRenderer.render(representation)
|
||||||
|
versions = TagGroupVersion.where(tag_group_id: version.tag_group_id)
|
||||||
|
pred = versions.find_by(version_no: version.version_no - 1)&.id
|
||||||
|
succ = versions.find_by(version_no: version.version_no + 1)&.id
|
||||||
|
updated_at = version.created_at
|
||||||
|
render json: {
|
||||||
|
'id' => version.tag_group_id,
|
||||||
|
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
||||||
|
'deprecated_at' => nil,
|
||||||
|
'body' => body,
|
||||||
|
'revision_id' => version.id,
|
||||||
|
'pred' => pred,
|
||||||
|
'succ' => succ,
|
||||||
|
'updated_at' => updated_at }
|
||||||
|
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
|
||||||
|
render_unprocessable_entity(error.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_group_version group
|
||||||
|
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_version group, version_id
|
||||||
|
TagGroupVersion.find_by(tag_group_id: group.id, id: version_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def wiki_diff from_body, to_body
|
||||||
|
diffs = Diff::LCS.sdiff(from_body.to_s.lines, to_body.to_s.lines)
|
||||||
|
diffs.map { |change|
|
||||||
|
case change.action
|
||||||
|
when ?=
|
||||||
|
{ type: 'context', content: change.old_element }
|
||||||
|
when ?!
|
||||||
|
[{ type: 'removed', content: change.old_element },
|
||||||
|
{ type: 'added', content: change.new_element }]
|
||||||
|
when ?+
|
||||||
|
{ type: 'added', content: change.new_element }
|
||||||
|
when ?-
|
||||||
|
{ type: 'removed', content: change.old_element }
|
||||||
|
end
|
||||||
|
}.flatten.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_group_changes versions
|
||||||
|
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
|
||||||
|
groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id)
|
||||||
|
predecessors = TagGroupVersion.where(tag_group_id: groups.keys,
|
||||||
|
version_no: rows.map { _1.version_no - 1 })
|
||||||
|
.pluck(:tag_group_id, :version_no, :id)
|
||||||
|
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
|
||||||
|
rows.map do |version|
|
||||||
|
group = groups.fetch(version.tag_group_id)
|
||||||
|
user = version.created_by_user
|
||||||
|
{ revision_id: version.id,
|
||||||
|
pred: predecessors[[group.id, version.version_no - 1]],
|
||||||
|
succ: nil,
|
||||||
|
wiki_page: { id: group.id, title: "#{ TagGroupRepr::PREFIX }#{ version.name }",
|
||||||
|
deprecated_at: nil },
|
||||||
|
user: user && { id: user.id, name: user.name },
|
||||||
|
kind: 'content', message: nil, timestamp: version.created_at }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def render_wiki_page_or_404 page
|
def render_wiki_page_or_404 page
|
||||||
return head :not_found unless page
|
return head :not_found unless page
|
||||||
|
|
||||||
@@ -273,12 +386,11 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
def unmigrated_wiki_pages
|
def unmigrated_wiki_pages
|
||||||
descriptions = TagDescription.where(language_code: 'ja')
|
descriptions = TagDescription.where(language_code: 'ja')
|
||||||
tag_groups = TagGroup.all
|
|
||||||
|
|
||||||
WikiPage
|
WikiPage
|
||||||
.joins(:tag_name)
|
.joins(:tag_name)
|
||||||
.where.not(id: descriptions.select(:id))
|
.where.not(id: descriptions.select(:id))
|
||||||
.where.not(id: tag_groups.select(:id))
|
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
|
||||||
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
||||||
descriptions.select(:tag_id))
|
descriptions.select(:tag_id))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
class TagGroup < ApplicationRecord
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
|
has_many :tag_group_tags, dependent: :delete_all
|
||||||
|
has_many :tags, through: :tag_group_tags
|
||||||
|
has_many :tag_group_groups, dependent: :delete_all
|
||||||
|
has_many :child_tag_groups, through: :tag_group_groups
|
||||||
|
has_many :placements, -> { order(:position) },
|
||||||
|
class_name: 'TagGroupPlacement', dependent: :delete_all
|
||||||
|
has_many :versions, -> { order(:version_no) },
|
||||||
|
class_name: 'TagGroupVersion', dependent: :delete_all
|
||||||
|
|
||||||
|
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
|
||||||
|
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class TagGroupGroup < ApplicationRecord
|
||||||
|
self.primary_key = [:tag_group_id, :child_tag_group_id]
|
||||||
|
|
||||||
|
belongs_to :tag_group
|
||||||
|
belongs_to :child_tag_group, class_name: 'TagGroup'
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class TagGroupPlacement < ApplicationRecord
|
||||||
|
self.primary_key = [:tag_group_id, :position]
|
||||||
|
|
||||||
|
belongs_to :tag_group
|
||||||
|
belongs_to :tag, optional: true
|
||||||
|
belongs_to :child_tag_group, class_name: 'TagGroup', optional: true
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class TagGroupTag < ApplicationRecord
|
||||||
|
self.primary_key = [:tag_group_id, :tag_id]
|
||||||
|
|
||||||
|
belongs_to :tag_group
|
||||||
|
belongs_to :tag
|
||||||
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class TagGroupVersion < ApplicationRecord
|
||||||
|
include VersionRecord
|
||||||
|
|
||||||
|
belongs_to :tag_group
|
||||||
|
|
||||||
|
scope :ordered, -> { order(:version_no) }
|
||||||
|
end
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
module TagGroupRepr
|
||||||
|
PREFIX = 'タググループ:'
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def many groups
|
||||||
|
ids = groups.map(&:id)
|
||||||
|
return [] if ids.empty?
|
||||||
|
|
||||||
|
tag_members = TagGroupTag.where(tag_group_id: ids)
|
||||||
|
.group_by(&:tag_group_id)
|
||||||
|
group_members = TagGroupGroup.where(tag_group_id: ids)
|
||||||
|
.group_by(&:tag_group_id)
|
||||||
|
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
|
||||||
|
.group_by(&:tag_group_id)
|
||||||
|
|
||||||
|
target_ids = tag_members.values.flatten.map(&:tag_id) +
|
||||||
|
group_members.values.flatten.map(&:child_tag_group_id) +
|
||||||
|
placements.values.flatten.filter_map(&:tag_id)
|
||||||
|
child_ids = group_members.values.flatten.map(&:child_tag_group_id) +
|
||||||
|
placements.values.flatten.filter_map(&:child_tag_group_id)
|
||||||
|
tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id)
|
||||||
|
names = TagName.where(tag_id: target_ids.uniq, language_code: 'ja', primary_flg: true)
|
||||||
|
.pluck(:tag_id, :name).to_h
|
||||||
|
child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id)
|
||||||
|
|
||||||
|
groups.map do |group|
|
||||||
|
group_json(group,
|
||||||
|
tag_members.fetch(group.id, []),
|
||||||
|
group_members.fetch(group.id, []),
|
||||||
|
placements.fetch(group.id, []), tags, names, child_groups)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def one group
|
||||||
|
many([group]).first
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_group wiki_page
|
||||||
|
{ 'id' => wiki_page.id,
|
||||||
|
'title' => "#{ PREFIX }#{ wiki_page.name }",
|
||||||
|
'deprecated_at' => nil,
|
||||||
|
'created_at' => wiki_page.created_at,
|
||||||
|
'updated_at' => wiki_page.updated_at }
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_version group
|
||||||
|
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
|
||||||
|
end
|
||||||
|
|
||||||
|
def historical version
|
||||||
|
members = Array(version.members)
|
||||||
|
placements = Array(version.placements)
|
||||||
|
tag_references = members.filter_map { |member| member['tag_id'] } +
|
||||||
|
collect_ids(placements, 'tag_id')
|
||||||
|
group_references = members.filter_map { |member| member['tag_group_id'] } +
|
||||||
|
collect_ids(placements, 'tag_group_id')
|
||||||
|
tag_versions = historical_tag_versions(tag_references)
|
||||||
|
group_versions = historical_group_versions(group_references)
|
||||||
|
tag_version_numbers = members.filter_map do |member|
|
||||||
|
next unless member['tag_id']
|
||||||
|
|
||||||
|
[member.fetch('tag_id'), member.fetch('version_no')]
|
||||||
|
end.to_h
|
||||||
|
group_version_numbers = members.filter_map do |member|
|
||||||
|
next unless member['tag_group_id']
|
||||||
|
|
||||||
|
[member.fetch('tag_group_id'), member.fetch('version_no')]
|
||||||
|
end.to_h
|
||||||
|
historical_members = members.map do |member|
|
||||||
|
historical_member(member, tag_versions, group_versions)
|
||||||
|
end
|
||||||
|
historical_placements = placements.map do |node|
|
||||||
|
historical_placement(node, tag_versions, group_versions,
|
||||||
|
tag_version_numbers, group_version_numbers)
|
||||||
|
end
|
||||||
|
{ 'id' => version.tag_group_id,
|
||||||
|
'name' => version.name,
|
||||||
|
'description' => version.description,
|
||||||
|
'version_no' => version.version_no,
|
||||||
|
'members' => historical_members,
|
||||||
|
'placements' => historical_placements,
|
||||||
|
'created_at' => version.created_at,
|
||||||
|
'updated_at' => version.created_at,
|
||||||
|
'created_by_user' => user_json(version.created_by_user),
|
||||||
|
'updated_by_user' => user_json(version.created_by_user) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_json group, tag_members, group_members, rows, tags, names, child_groups
|
||||||
|
tree = placement_tree(rows, tags, names, child_groups)
|
||||||
|
members = tag_members.sort_by(&:tag_id).map do |member|
|
||||||
|
{ 'tag' => tag_json(tags.fetch(member.tag_id), names.fetch(member.tag_id)) }
|
||||||
|
end
|
||||||
|
members.concat(group_members.sort_by(&:child_tag_group_id).map do |member|
|
||||||
|
{ 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) }
|
||||||
|
end)
|
||||||
|
{ 'id' => group.id,
|
||||||
|
'name' => group.name,
|
||||||
|
'description' => group.description,
|
||||||
|
'version_no' => group.version_no,
|
||||||
|
'members' => members,
|
||||||
|
'placements' => tree,
|
||||||
|
'created_at' => group.created_at,
|
||||||
|
'updated_at' => group.updated_at,
|
||||||
|
'created_by_user' => user_json(group.created_by_user),
|
||||||
|
'updated_by_user' => user_json(group.updated_by_user) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_json user
|
||||||
|
UserRepr.base(user) if user
|
||||||
|
end
|
||||||
|
|
||||||
|
def placement_tree rows, tags, names, child_groups
|
||||||
|
by_parent = rows.group_by(&:parent_position)
|
||||||
|
build = lambda do |parent|
|
||||||
|
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
|
||||||
|
node = if row.tag_id
|
||||||
|
{ 'tag' => tag_json(tags.fetch(row.tag_id), names.fetch(row.tag_id)) }
|
||||||
|
elsif row.child_tag_group_id
|
||||||
|
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
|
||||||
|
else
|
||||||
|
{ 'section_name' => row.section_name, 'description' => row.description }
|
||||||
|
end
|
||||||
|
node.merge('children' => build.call(row.position))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
build.call(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag_json tag, name
|
||||||
|
{ 'id' => tag.id, 'name' => name, 'category' => tag.category }
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_json_reference group
|
||||||
|
{ 'id' => group.id, 'name' => group.name }
|
||||||
|
end
|
||||||
|
|
||||||
|
def collect_ids nodes, key
|
||||||
|
nodes.flat_map do |node|
|
||||||
|
[node[key], *collect_ids(Array(node['children']), key)].compact
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def historical_tag_versions ids
|
||||||
|
TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version|
|
||||||
|
[version.tag_id, version.version_no]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def historical_group_versions ids
|
||||||
|
TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version|
|
||||||
|
[version.tag_group_id, version.version_no]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def historical_member member, tag_versions, group_versions
|
||||||
|
if member['tag_id']
|
||||||
|
id = member.fetch('tag_id')
|
||||||
|
version = tag_versions.fetch([id, member.fetch('version_no')])
|
||||||
|
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
|
||||||
|
'category' => version.category } }
|
||||||
|
else
|
||||||
|
id = member.fetch('tag_group_id')
|
||||||
|
version = group_versions.fetch([id, member.fetch('version_no')])
|
||||||
|
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def historical_placement(node, tag_versions, group_versions,
|
||||||
|
tag_version_numbers, group_version_numbers)
|
||||||
|
result = if node['tag_id']
|
||||||
|
id = node.fetch('tag_id')
|
||||||
|
version_no = tag_version_numbers.fetch(id)
|
||||||
|
version = tag_versions.fetch([id, version_no])
|
||||||
|
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
|
||||||
|
'category' => version.category } }
|
||||||
|
elsif node['tag_group_id']
|
||||||
|
id = node.fetch('tag_group_id')
|
||||||
|
version_no = group_version_numbers.fetch(id)
|
||||||
|
version = group_versions.fetch([id, version_no])
|
||||||
|
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
|
||||||
|
else
|
||||||
|
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
|
||||||
|
end
|
||||||
|
result.merge('children' => Array(node['children']).map do |child|
|
||||||
|
historical_placement(child, tag_versions, group_versions,
|
||||||
|
tag_version_numbers, group_version_numbers)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def japanese_version_name version
|
||||||
|
version.names.fetch('ja').fetch('name')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -25,6 +25,10 @@ module WikiPageRepr
|
|||||||
'deprecated_at' => tag.deprecated_at }
|
'deprecated_at' => tag.deprecated_at }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def tag_group tag_group
|
||||||
|
TagGroupRepr.tag_group(tag_group)
|
||||||
|
end
|
||||||
|
|
||||||
def many wiki_pages
|
def many wiki_pages
|
||||||
wiki_pages.map { |p| base(p) }
|
wiki_pages.map { |p| base(p) }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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 = []
|
||||||
|
blocks << clean_text(@group['description']) if @group['description'].present?
|
||||||
|
|
||||||
|
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, 1) })
|
||||||
|
else
|
||||||
|
blocks << render_list(placements, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
blocks.compact.reject(&:empty?).join("\n\n").sub(/\n+\z/, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_membership! placements
|
||||||
|
placed = placement_members(placements).uniq.sort
|
||||||
|
members = @group.fetch('members').map do |member|
|
||||||
|
if member['tag']
|
||||||
|
['tag', member.fetch('tag').fetch('id')]
|
||||||
|
else
|
||||||
|
['tag_group', member.fetch('tag_group').fetch('id')]
|
||||||
|
end
|
||||||
|
end.uniq.sort
|
||||||
|
missing = members - placed
|
||||||
|
raise UnsupportedStructure, '掲載位置のない membership は Markdown にできません.' if missing.any?
|
||||||
|
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\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
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
resources :tag_groups, only: [:index, :show]
|
||||||
|
|
||||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||||
|
|
||||||
scope 'tags/:parent_id/children', controller: :tag_children do
|
scope 'tags/:parent_id/children', controller: :tag_children do
|
||||||
|
|||||||
@@ -114,4 +114,26 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
|
|||||||
- 洗澡鹿
|
- 洗澡鹿
|
||||||
MARKDOWN
|
MARKDOWN
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects mixed top-level heading Sections and Tags without losing structure' do
|
||||||
|
id = seed_group(
|
||||||
|
name: '混在構造', members: [tag_member(series)],
|
||||||
|
placements: [
|
||||||
|
{ 'section_name' => '区分', 'description' => nil, 'children' => [] },
|
||||||
|
tag_node(series)])
|
||||||
|
|
||||||
|
get group_title_path('混在構造')
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects membership without a placement instead of omitting it' do
|
||||||
|
id = seed_group(name: '未配置構造', members: [tag_member(series)])
|
||||||
|
|
||||||
|
get group_title_path('未配置構造')
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -79,6 +79,82 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
'タググループ:読取り契約', help.title, '新タグ')
|
'タググループ:読取り契約', help.title, '新タグ')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'keeps Help readable when its ID collides with a new group' do
|
||||||
|
help = Wiki::Commit.create_legacy_content!(
|
||||||
|
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
|
||||||
|
created_by_user: user)
|
||||||
|
seed_group(name: '別 ID重複', id: help.id, description: 'group body')
|
||||||
|
|
||||||
|
get '/wiki'
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { _1.fetch('title') }).to include(help.title, 'タググループ:別 ID重複')
|
||||||
|
|
||||||
|
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('body')).to eq('help body')
|
||||||
|
|
||||||
|
get '/wiki/changes'
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { _1.dig('wiki_page', 'title') }).to include(
|
||||||
|
help.title, 'タググループ:別 ID重複')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'finds groups by a partial prefix of their displayed title' do
|
||||||
|
get '/wiki', params: { title: 'タググル' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.map { _1.fetch('title') }).to include('タググループ:読取り契約')
|
||||||
|
expect(json.find { _1.fetch('title') == 'タググループ:読取り契約' })
|
||||||
|
.to include('updated_at' => be_present)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not restore a stale namespaced Wiki page after a group rename' do
|
||||||
|
old_page = legacy_group('旧名称', '- 旧本文')
|
||||||
|
seed_group(name: '新名称', id: old_page.id, description: '正本')
|
||||||
|
|
||||||
|
get group_title_path('旧名称')
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
get "#{ group_title_path('旧名称') }/exists"
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
get group_title_path('新名称')
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('body').strip).to eq('正本')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'orders mixed resource history by timestamp rather than independent IDs' do
|
||||||
|
help = Wiki::Commit.create_legacy_content!(
|
||||||
|
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
|
||||||
|
created_by_user: user)
|
||||||
|
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
|
||||||
|
TagGroupVersion.update_all(created_at: 3.days.ago)
|
||||||
|
WikiRevision.where(wiki_page_id: help.id).update_all(created_at: 2.days.ago)
|
||||||
|
TagDescriptionVersion.update_all(created_at: 1.day.ago)
|
||||||
|
|
||||||
|
get '/wiki/changes'
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
|
||||||
|
expect(timestamps).to eq(timestamps.sort.reverse)
|
||||||
|
titles = json.map { _1.dig('wiki_page', 'title') }
|
||||||
|
expect(titles.index('新タグ')).to be < titles.index(help.title)
|
||||||
|
expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'renders nullable group editors without raising' do
|
||||||
|
group_connection.execute(
|
||||||
|
"UPDATE tag_groups SET created_by_user_id = NULL, updated_by_user_id = NULL " +
|
||||||
|
"WHERE id = #{ page.id }")
|
||||||
|
group_connection.execute(
|
||||||
|
"UPDATE tag_group_versions SET created_by_user_id = NULL " +
|
||||||
|
"WHERE tag_group_id = #{ page.id }")
|
||||||
|
|
||||||
|
get "/tag_groups/#{ page.id }"
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json).to include('created_by_user' => nil, 'updated_by_user' => nil)
|
||||||
|
get "/wiki/title/#{ CGI.escapeURIComponent('タググループ:読取り契約') }"
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
it 'reads group version IDs, editor and timestamps in scoped changes' do
|
it 'reads group version IDs, editor and timestamps in scoped changes' do
|
||||||
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
|
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする