From 2052e14b185cd0311ba8e43c73328aeebcb2cf34 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 9 Oct 2026 22:25:59 +0900 Subject: [PATCH] #425 --- .../app/controllers/tag_groups_controller.rb | 13 ++ .../app/controllers/wiki_pages_controller.rb | 168 ++++++++++++--- backend/app/models/tag_group.rb | 16 ++ backend/app/models/tag_group_group.rb | 7 + backend/app/models/tag_group_placement.rb | 9 + backend/app/models/tag_group_tag.rb | 7 + backend/app/models/tag_group_version.rb | 7 + backend/app/representations/tag_group_repr.rb | 194 ++++++++++++++++++ backend/app/representations/wiki_page_repr.rb | 4 + .../services/tag_group_markdown_renderer.rb | 114 ++++++++++ backend/config/routes.rb | 2 + .../spec/requests/tag_group_markdown_spec.rb | 22 ++ backend/spec/requests/tag_group_wiki_spec.rb | 76 +++++++ 13 files changed, 611 insertions(+), 28 deletions(-) create mode 100644 backend/app/controllers/tag_groups_controller.rb create mode 100644 backend/app/models/tag_group.rb create mode 100644 backend/app/models/tag_group_group.rb create mode 100644 backend/app/models/tag_group_placement.rb create mode 100644 backend/app/models/tag_group_tag.rb create mode 100644 backend/app/models/tag_group_version.rb create mode 100644 backend/app/representations/tag_group_repr.rb create mode 100644 backend/app/services/tag_group_markdown_renderer.rb diff --git a/backend/app/controllers/tag_groups_controller.rb b/backend/app/controllers/tag_groups_controller.rb new file mode 100644 index 0000000..e743c00 --- /dev/null +++ b/backend/app/controllers/tag_groups_controller.rb @@ -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 diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 958537d..5de1d1e 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -2,26 +2,25 @@ class WikiPagesController < ApplicationController rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict def index - # 互換性のための化石リソース - # TODO: ヘルプ追加後にヘルプからも取得する作りに変更する. - title = params[:title].to_s.strip descriptions = TagDescription.where(language_code: 'ja') .includes(tag: :tag_names) pages = unmigrated_wiki_pages.includes(tag_name: :tag) + tag_groups = TagGroup.all if title.present? pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" descriptions = descriptions.joins(tag: :tag_names) .where(tag_names: { language_code: 'ja', primary_flg: true }) .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) end 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 = json.first(20) if title.present? render json: @@ -32,6 +31,9 @@ class WikiPagesController < ApplicationController return render_wiki_page_by_tag_description tag_description if tag_description + tag_group = TagGroup.find_by(id: params[:id]) + return render_tag_group tag_group if tag_group + page = WikiPage.joins(:tag_name) .includes(tag_name: :tag) .find_by(id: params[:id]) @@ -42,6 +44,13 @@ class WikiPagesController < ApplicationController def show_by_title title = params[:title].to_s.strip + if title.start_with?(TagGroupRepr::PREFIX) + name = title.delete_prefix(TagGroupRepr::PREFIX) + tag_group = TagGroup.find_by(name:) + return render_tag_group tag_group if tag_group + return head :not_found + end + tag_description = tag_description_by_title(title) return render_wiki_page_by_tag_description tag_description if tag_description @@ -55,12 +64,17 @@ class WikiPagesController < ApplicationController def exists return head :no_content if TagDescription.exists?(id: params[:id]) + return head :no_content if TagGroup.exists?(id: params[:id]) head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found end def exists_by_title title = params[:title].to_s.strip + if title.start_with?(TagGroupRepr::PREFIX) + exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX)) + return head exists ? :no_content : :not_found + end return head :no_content if tag_description_by_title(title) exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) @@ -81,29 +95,33 @@ class WikiPagesController < ApplicationController to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no) resource = WikiPageRepr.tag_description(description) else - page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) - from_rev = from && page.wiki_revisions.find(from) - to_rev = to ? page.wiki_revisions.find(to) : page.current_revision - if ((from_rev && !from_rev.content?) || !to_rev&.content?) - return render_unprocessable_entity('差分を表示できない版です.') + tag_group = TagGroup.find_by(id:) + if tag_group + from_rev = from && group_version(tag_group, from) + to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group) + return head :not_found if (from && !from_rev) || !to_rev + + from_body = from_rev && render_group_version(from_rev) + to_body = render_group_version(to_rev) + resource = { 'id' => tag_group.id, + 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", + 'deprecated_at' => nil } + else + page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) + from_rev = from && page.wiki_revisions.find(from) + to_rev = to ? page.wiki_revisions.find(to) : page.current_revision + if ((from_rev && !from_rev.content?) || !to_rev&.content?) + return render_unprocessable_entity('差分を表示できない版です.') + end + from_body = from_rev&.body + to_body = to_rev.body + resource = WikiPageRepr.base(page) end - resource = WikiPageRepr.base(page) end - diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) - diff_json = diffs.map { |change| - case change.action - when ?= - { type: 'context', content: change.old_element } - when ?! - [{ type: 'removed', content: change.old_element }, - { type: 'added', content: change.new_element }] - when ?+ - { type: 'added', content: change.new_element } - when ?- - { type: 'removed', content: change.old_element } - end - }.flatten.compact + from_body ||= from_rev&.body + to_body ||= to_rev.body + diff_json = wiki_diff(from_body, to_body) render json: { wiki_page_id: resource.fetch('id'), title: resource.fetch('title'), @@ -111,6 +129,8 @@ class WikiPagesController < ApplicationController older_revision_id: from_rev&.id, newer_revision_id: to_rev.id, diff: diff_json } + rescue TagGroupMarkdownRenderer::UnsupportedStructure => error + render_unprocessable_entity(error.message) end def create @@ -199,6 +219,11 @@ class WikiPagesController < ApplicationController return render json: description_changes(TagDescription.where(id: description.id)) end + tag_group = id && TagGroup.find_by(id:) + if tag_group + return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id)) + end + pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages revisions = WikiRevision.joins(wiki_page: :tag_name) .where(wiki_page_id: pages.select(:id)) @@ -217,14 +242,102 @@ class WikiPagesController < ApplicationController timestamp: rev.created_at } } unless id + json.concat(tag_group_changes(TagGroupVersion.all)) json.concat(description_changes(TagDescription.where(language_code: 'ja'))) - json.sort_by! { |row| -row[:revision_id] } + json = json.each_with_index.sort_by do |row, index| + [-row.fetch(:timestamp).to_f, index] + end.map(&:first) end render json: json.first(200) end private + def 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 return head :not_found unless page @@ -273,12 +386,11 @@ class WikiPagesController < ApplicationController def unmigrated_wiki_pages descriptions = TagDescription.where(language_code: 'ja') - tag_groups = TagGroup.all WikiPage .joins(:tag_name) .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 (?)', descriptions.select(:tag_id)) end diff --git a/backend/app/models/tag_group.rb b/backend/app/models/tag_group.rb new file mode 100644 index 0000000..3533641 --- /dev/null +++ b/backend/app/models/tag_group.rb @@ -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 diff --git a/backend/app/models/tag_group_group.rb b/backend/app/models/tag_group_group.rb new file mode 100644 index 0000000..6175694 --- /dev/null +++ b/backend/app/models/tag_group_group.rb @@ -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 diff --git a/backend/app/models/tag_group_placement.rb b/backend/app/models/tag_group_placement.rb new file mode 100644 index 0000000..8dde6fa --- /dev/null +++ b/backend/app/models/tag_group_placement.rb @@ -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 diff --git a/backend/app/models/tag_group_tag.rb b/backend/app/models/tag_group_tag.rb new file mode 100644 index 0000000..314a7db --- /dev/null +++ b/backend/app/models/tag_group_tag.rb @@ -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 diff --git a/backend/app/models/tag_group_version.rb b/backend/app/models/tag_group_version.rb new file mode 100644 index 0000000..95257e9 --- /dev/null +++ b/backend/app/models/tag_group_version.rb @@ -0,0 +1,7 @@ +class TagGroupVersion < ApplicationRecord + include VersionRecord + + belongs_to :tag_group + + scope :ordered, -> { order(:version_no) } +end diff --git a/backend/app/representations/tag_group_repr.rb b/backend/app/representations/tag_group_repr.rb new file mode 100644 index 0000000..98a5cb4 --- /dev/null +++ b/backend/app/representations/tag_group_repr.rb @@ -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 diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index a217341..12fb3b6 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -25,6 +25,10 @@ module WikiPageRepr 'deprecated_at' => tag.deprecated_at } end + def tag_group tag_group + TagGroupRepr.tag_group(tag_group) + end + def many wiki_pages wiki_pages.map { |p| base(p) } end diff --git a/backend/app/services/tag_group_markdown_renderer.rb b/backend/app/services/tag_group_markdown_renderer.rb new file mode 100644 index 0000000..e9da768 --- /dev/null +++ b/backend/app/services/tag_group_markdown_renderer.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 4402503..a6c8a27 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + resources :tag_groups, only: [:index, :show] + resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] scope 'tags/:parent_id/children', controller: :tag_children do diff --git a/backend/spec/requests/tag_group_markdown_spec.rb b/backend/spec/requests/tag_group_markdown_spec.rb index 79d571d..8a06381 100644 --- a/backend/spec/requests/tag_group_markdown_spec.rb +++ b/backend/spec/requests/tag_group_markdown_spec.rb @@ -114,4 +114,26 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: : - 洗澡鹿 MARKDOWN 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 diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb index e545729..f410450 100644 --- a/backend/spec/requests/tag_group_wiki_spec.rb +++ b/backend/spec/requests/tag_group_wiki_spec.rb @@ -79,6 +79,82 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do 'タググループ:読取り契約', help.title, '新タグ') 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 without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } } expect(response).to have_http_status(:ok)