| @@ -1,8 +1,8 @@ | |||||
| class WikiPagesController < ApplicationController | class WikiPagesController < ApplicationController | ||||
| def index | |||||
| wiki_pages = WikiPage.all | |||||
| rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict | |||||
| render json: wiki_pages | |||||
| def index | |||||
| render json: WikiPage.all | |||||
| end | end | ||||
| def show | def show | ||||
| @@ -35,12 +35,13 @@ class WikiPagesController < ApplicationController | |||||
| to = params[:to].presence | to = params[:to].presence | ||||
| return head :bad_request if id.blank? || from.blank? | return head :bad_request if id.blank? || from.blank? | ||||
| wiki_page_from = WikiPage.find(id) | |||||
| wiki_page_to = WikiPage.find(id) | |||||
| wiki_page_from.sha = from | |||||
| wiki_page_to.sha = to | |||||
| page = WikiPage.find(id) | |||||
| diffs = Diff::LCS.sdiff(wiki_page_from.body, wiki_page_to.body) | |||||
| from_rev = page.wiki_revisions.find(from) | |||||
| to_rev = to ? page.wiki_revisions.find(to) : page.current_revision | |||||
| return head :unprocessable_entity if !(from_rev&.content?) || !(to_rev&.content?) | |||||
| diffs = Diff::LCS.sdiff(from_rev.body.lines, to_rev.body.lines) | |||||
| diff_json = diffs.map { |change| | diff_json = diffs.map { |change| | ||||
| case change.action | case change.action | ||||
| when ?= | when ?= | ||||
| @@ -55,23 +56,32 @@ class WikiPagesController < ApplicationController | |||||
| end | end | ||||
| }.flatten.compact | }.flatten.compact | ||||
| render json: { wiki_page_id: wiki_page_from.id, | |||||
| title: wiki_page_from.title, | |||||
| older_sha: wiki_page_from.sha, | |||||
| newer_sha: wiki_page_to.sha, | |||||
| diff: diff_json } | |||||
| render json: { wiki_page_id: page.id, | |||||
| title: page.title, | |||||
| older_revision_id: from_rev.id, | |||||
| newer_revision_id: to_rev.id, | |||||
| diff: diff_json } | |||||
| end | end | ||||
| def create | def create | ||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless ['admin', 'member'].include?(current_user.role) | |||||
| return head :forbidden unless current_user.member? | |||||
| title = params[:title]&.strip | |||||
| body = params[:body].to_s | |||||
| return head :unprocessable_entity if title.blank? || body.blank? | |||||
| page = WikiPage.new(title:) | |||||
| wiki_page = WikiPage.new(title: params[:title], created_user: current_user, updated_user: current_user) | |||||
| if wiki_page.save | |||||
| wiki_page.set_body params[:body], user: current_user | |||||
| render json: wiki_page, status: :created | |||||
| if page.save | |||||
| message = params[:message].presence | |||||
| Wiki::Commit.content!(page:, body:, created_user: current_user, message:) | |||||
| render json: page, status: :created | |||||
| else | else | ||||
| render json: { errors: wiki_page.errors.full_messages }, status: :unprocessable_entity | |||||
| render json: { errors: page.errors.full_messages }, | |||||
| status: :unprocessable_entity | |||||
| end | end | ||||
| end | end | ||||
| @@ -79,16 +89,19 @@ class WikiPagesController < ApplicationController | |||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless current_user.member? | return head :forbidden unless current_user.member? | ||||
| title = params[:title] | |||||
| body = params[:body] | |||||
| title = params[:title]&.strip | |||||
| body = params[:body].to_s | |||||
| return head :unprocessable_entity if title.blank? || body.blank? | return head :unprocessable_entity if title.blank? || body.blank? | ||||
| wiki_page = WikiPage.find(params[:id]) | |||||
| wiki_page.title = title | |||||
| wiki_page.updated_user = current_user | |||||
| wiki_page.set_body(body, user: current_user) | |||||
| wiki_page.save! | |||||
| page = WikiPage.find(params[:id]) | |||||
| if params[:title].present? && params[:title].strip != page.title | |||||
| return head :unprocessable_entity | |||||
| end | |||||
| message = params[:message].presence | |||||
| Wiki::Commit.content!(page:, body:, created_user: current_user, message:) | |||||
| head :ok | head :ok | ||||
| end | end | ||||
| @@ -97,55 +110,73 @@ class WikiPagesController < ApplicationController | |||||
| title = params[:title]&.strip | title = params[:title]&.strip | ||||
| q = WikiPage.all | q = WikiPage.all | ||||
| q = q.where('title LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") if title.present? | |||||
| if title.present? | |||||
| q = q.where('title LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") | |||||
| end | |||||
| render json: q.limit(20) | render json: q.limit(20) | ||||
| end | end | ||||
| def changes | def changes | ||||
| id = params[:id] | |||||
| log = if id.present? | |||||
| wiki.page("#{ id }.md")&.versions | |||||
| else | |||||
| wiki.repo.log('main', nil) | |||||
| end | |||||
| return render json: [] unless log | |||||
| render json: log.map { |commit| | |||||
| wiki_page = WikiPage.find(commit.message.split(' ')[1].to_i) | |||||
| wiki_page.sha = commit.id | |||||
| next nil if wiki_page.sha.blank? | |||||
| user = User.find(commit.author.name.to_i) | |||||
| { sha: wiki_page.sha, | |||||
| pred: wiki_page.pred, | |||||
| succ: wiki_page.succ, | |||||
| wiki_page: wiki_page && { id: wiki_page.id, title: wiki_page.title }, | |||||
| user: user && { id: user.id, name: user.name }, | |||||
| change_type: commit.message.split(' ')[0].downcase[0...(-1)], | |||||
| timestamp: commit.authored_date } | |||||
| id = params[:id].presence | |||||
| q = WikiRevision.includes(:wiki_page, :created_user).order(id: :desc) | |||||
| q = q.where(wiki_page_id: id) if id | |||||
| render json: q.limit(200).map { |rev| | |||||
| { revision_id: rev.id, | |||||
| pred: rev.base_revision_id, | |||||
| succ: nil, | |||||
| wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title }, | |||||
| user: { id: rev.created_user.id, name: rev.created_user.name }, | |||||
| kind: rev.kind, | |||||
| message: rev.message, | |||||
| timestamp: rev.created_at } | |||||
| }.compact | }.compact | ||||
| end | end | ||||
| private | private | ||||
| WIKI_PATH = Rails.root.join('wiki').to_s | |||||
| def render_wiki_page_or_404 page | |||||
| return head :not_found unless page | |||||
| def wiki | |||||
| @wiki ||= Gollum::Wiki.new(WIKI_PATH) | |||||
| end | |||||
| if params[:version].present? | |||||
| rev = page.wiki_revisions.find_by(id: params[:version]) | |||||
| return head :not_found unless rev | |||||
| def render_wiki_page_or_404 wiki_page | |||||
| return head :not_found unless wiki_page | |||||
| if rev.redirect? | |||||
| return ( | |||||
| redirect_to wiki_page_by_title_path(title: rev.redirect_page.title), | |||||
| status: :moved_permanently) | |||||
| end | |||||
| body = rev.body | |||||
| revision_id = rev.id | |||||
| pred = page.pred_revision_id(revision_id) | |||||
| succ = page.succ_revision_id(revision_id) | |||||
| return render json: page.as_json.merge(body:, revision_id:, pred:, succ:) | |||||
| end | |||||
| wiki_page.sha = params[:version].presence | |||||
| rev = page.current_revision | |||||
| unless rev | |||||
| return render json: page.as_json.merge(body: nil, revision_id: nil, pred: nil, succ: nil) | |||||
| end | |||||
| if rev.redirect? | |||||
| return ( | |||||
| redirect_to wiki_page_by_title_path(title: rev.redirect_page.title), | |||||
| status: :moved_permanently) | |||||
| end | |||||
| body = rev.body | |||||
| revision_id = rev.id | |||||
| pred = page.pred_revision_id(revision_id) | |||||
| succ = page.succ_revision_id(revision_id) | |||||
| render json: page.as_json.merge(body:, revision_id:, pred:, succ:) | |||||
| end | |||||
| body = wiki_page.body | |||||
| sha = wiki_page.sha | |||||
| pred = wiki_page.pred | |||||
| succ = wiki_page.succ | |||||
| render json: wiki_page.as_json.merge(body:, sha:, pred:, succ:) | |||||
| def render_wiki_conflict err | |||||
| render json: { error: 'conflict', message: err.message }, status: :conflict | |||||
| end | end | ||||
| end | end | ||||
| @@ -0,0 +1,15 @@ | |||||
| class WikiLine < ApplicationRecord | |||||
| has_many :wiki_revision_lines, dependent: :restrict_with_exception | |||||
| validates :sha256, presence: true, uniqueness: true, length: { is: 64 } | |||||
| validates :body, presence: true | |||||
| def self.upsert_by_body! body | |||||
| sha = Digest::SHA256.hexdigest(body) | |||||
| now = Time.current | |||||
| upsert({ sha256: sha, body:, created_at: now, updated_at: now }, unique_by: :sha256) | |||||
| find_by!(sha256: sha) | |||||
| end | |||||
| end | |||||
| @@ -1,80 +1,48 @@ | |||||
| require 'gollum-lib' | |||||
| require 'set' | |||||
| class WikiPage < ApplicationRecord | class WikiPage < ApplicationRecord | ||||
| belongs_to :tag, optional: true | |||||
| belongs_to :created_user, class_name: 'User', foreign_key: 'created_user_id' | |||||
| belongs_to :updated_user, class_name: 'User', foreign_key: 'updated_user_id' | |||||
| has_many :wiki_revisions, dependent: :destroy | |||||
| has_many :redirected_from_revisions, | |||||
| class_name: 'WikiRevision', | |||||
| foreign_key: :redirect_page_id, | |||||
| dependent: :nullify | |||||
| validates :title, presence: true, length: { maximum: 255 }, uniqueness: true | validates :title, presence: true, length: { maximum: 255 }, uniqueness: true | ||||
| def as_json options = { } | |||||
| self.sha = nil | |||||
| super options | |||||
| def current_revision | |||||
| wiki_revisions.order(id: :desc).first | |||||
| end | end | ||||
| def sha= val | |||||
| if val.present? | |||||
| @sha = val | |||||
| @page = wiki.page("#{ id }.md", @sha) | |||||
| else | |||||
| @page = wiki.page("#{ id }.md") | |||||
| @sha = @page.versions.first.id | |||||
| end | |||||
| vers = @page.versions | |||||
| idx = vers.find_index { |ver| ver.id == @sha } | |||||
| if idx | |||||
| @pred = vers[idx + 1]&.id | |||||
| @succ = idx.positive? ? vers[idx - 1].id : nil | |||||
| @updated_at = vers[idx].authored_date | |||||
| else | |||||
| @sha = nil | |||||
| @pred = nil | |||||
| @succ = nil | |||||
| @updated_at = nil | |||||
| end | |||||
| @sha | |||||
| end | |||||
| def sha | |||||
| @sha | |||||
| def body | |||||
| rev = current_revision | |||||
| rev.body if rev&.content? | |||||
| end | end | ||||
| def pred | |||||
| @pred | |||||
| end | |||||
| def resolve_redirect limit: 10 | |||||
| page = self | |||||
| visited = Set.new | |||||
| def succ | |||||
| @succ | |||||
| end | |||||
| limit.times do | |||||
| return page if visited.include?(page.id) | |||||
| def updated_at | |||||
| @updated_at | |||||
| end | |||||
| visited.add(page.id) | |||||
| def body | |||||
| sha = nil unless @page | |||||
| @page&.raw_data&.force_encoding('UTF-8') | |||||
| end | |||||
| rev = page.current_revision | |||||
| return page if !(rev&.redirect?) || !(rev.redirect_page) | |||||
| def set_body content, user: | |||||
| commit_info = { name: user.id.to_s, | |||||
| email: 'dummy@example.com' } | |||||
| page = wiki.page("#{ id }.md") | |||||
| if page | |||||
| commit_info[:message] = "Updated #{ id }" | |||||
| wiki.update_page(page, id.to_s, :markdown, content, commit_info) | |||||
| else | |||||
| commit_info[:message] = "Created #{ id }" | |||||
| wiki.write_page(id.to_s, :markdown, content, commit_info) | |||||
| page = rev.redirect_page | |||||
| end | end | ||||
| end | |||||
| private | |||||
| page | |||||
| end | |||||
| WIKI_PATH = Rails.root.join('wiki').to_s | |||||
| def pred_revision_id revision_id | |||||
| wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) | |||||
| end | |||||
| def wiki | |||||
| @wiki ||= Gollum::Wiki.new(WIKI_PATH) | |||||
| def succ_revision_id revision_id | |||||
| wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id) | |||||
| end | end | ||||
| end | end | ||||
| @@ -0,0 +1,55 @@ | |||||
| class WikiRevision < ApplicationRecord | |||||
| belongs_to :wiki_page | |||||
| belongs_to :base_revision, class_name: 'WikiRevision', optional: true | |||||
| belongs_to :created_user, class_name: 'User' | |||||
| belongs_to :redirect_page, class_name: 'WikiPage', optional: true | |||||
| has_many :wiki_revision_lines, dependent: :delete_all | |||||
| has_many :wiki_lines, through: :wiki_revision_lines | |||||
| enum kind: { content: 0, redirect: 1 } | |||||
| validates :kind, presence: true | |||||
| validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } | |||||
| validates :tree_sha256, length: { is: 64 }, allow_nil: true | |||||
| validate :kind_consistency | |||||
| def body | |||||
| return unless content? | |||||
| wiki_revision_lines | |||||
| .includes(:wiki_line) | |||||
| .order(:position) | |||||
| .map { |rev| rev.wiki_line.body } | |||||
| .join("\n") | |||||
| end | |||||
| private | |||||
| def kind_consistency | |||||
| if content? | |||||
| if tree_sha256.blank? | |||||
| errors.add(:tree_sha256, '種類がページの場合は必須です.') | |||||
| end | |||||
| if redirect_page_id.present? | |||||
| errors.add(:redirect_page_id, '種類がページの場合は空である必要があります.') | |||||
| end | |||||
| end | |||||
| if redirect? | |||||
| if redirect_page_id.blank? | |||||
| errors.add(:redirect_page_id, '種類がリダイレクトの場合は必須です.') | |||||
| end | |||||
| if tree_sha256.present? | |||||
| errors.add(:tree_sha256, '種類がリダイレクトの場合は空である必要があります.') | |||||
| end | |||||
| if lines_count.to_i > 0 | |||||
| errors.add(:lines_count, '種類がリダイレクトの場合は 0 である必要があります.') | |||||
| end | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,8 @@ | |||||
| class WikiRevisionLine < ApplicationRecord | |||||
| belongs_to :wiki_revision | |||||
| belongs_to :wiki_line | |||||
| validates :position, presence: true, | |||||
| numericality: { only_integer: true, greater_than_or_equal_to: 0 } | |||||
| validates :position, uniqueness: { scope: :wiki_revision_id } | |||||
| end | |||||
| @@ -0,0 +1,122 @@ | |||||
| require 'digest' | |||||
| module Wiki | |||||
| class Commit | |||||
| class Conflict < StandardError | |||||
| ; | |||||
| end | |||||
| def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil | |||||
| new(page:, created_user:).content!(body:, message:, base_revision_id:) | |||||
| end | |||||
| def self.redirect! page:, redirect_page:, created_user:, message: nil, base_revision_id: nil | |||||
| new(page:, created_user:).redirect!(redirect_page:, message:, base_revision_id:) | |||||
| end | |||||
| def initialize page:, created_user: | |||||
| @page = page | |||||
| @created_user = created_user | |||||
| end | |||||
| def content! body:, message:, base_revision_id: | |||||
| normalised = normalise_body(body) | |||||
| lines = split_lines(normalised) | |||||
| line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } | |||||
| tree_sha = Digest::SHA256.hexdigest(line_shas.join(',')) | |||||
| line_id_by_sha = upsert_lines!(lines, line_shas) | |||||
| line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) } | |||||
| ActiveRecord::Base.transaction do | |||||
| @page.lock! | |||||
| if base_revision_id.present? | |||||
| current_id = @page.wiki_revisions.maximum(:id) | |||||
| if current_id && current_id != base_revision_id.to_i | |||||
| raise Conflict, | |||||
| "競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." | |||||
| end | |||||
| end | |||||
| rev = WikiRevision.create!( | |||||
| wiki_page: @page, | |||||
| base_revision_id:, | |||||
| created_user: @created_user, | |||||
| kind: :content, | |||||
| redirect_page_id: nil, | |||||
| message:, | |||||
| lines_count: lines.length, | |||||
| tree_sha256: tree_sha) | |||||
| rows = line_ids.each_with_index.map do |line_id, pos| | |||||
| { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } | |||||
| end | |||||
| WikiRevisionLine.insert_all!(rows) | |||||
| rev | |||||
| end | |||||
| end | |||||
| def redirect! redirect_page:, message:, base_revision_id: | |||||
| ActiveRecord::Base.transaction do | |||||
| @page.lock! | |||||
| if base_revision_id.present? | |||||
| current_id = @page.wiki_revisions.maximum(:id) | |||||
| if current_id && current_id != base_revision_id.to_i | |||||
| raise Conflict, | |||||
| "競合が発生してゐます(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." | |||||
| end | |||||
| end | |||||
| WikiRevision.create!( | |||||
| wiki_page: @page, | |||||
| base_revision_id:, | |||||
| created_user: @created_user, | |||||
| kind: :redirect, | |||||
| redirect_page:, | |||||
| message:, | |||||
| lines_count: 0, | |||||
| tree_sha256: nil) | |||||
| end | |||||
| end | |||||
| private | |||||
| def normalise_body body | |||||
| s = body.to_s | |||||
| s.gsub!("\r\n", "\n") | |||||
| s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') | |||||
| end | |||||
| def split_lines body | |||||
| body.split("\n") | |||||
| end | |||||
| def upsert_lines! lines, line_shas | |||||
| now = Time.current | |||||
| id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h | |||||
| missing_rows = [] | |||||
| line_shas.each_with_index do |sha, i| | |||||
| next if id_by_sha.key?(sha) | |||||
| missing_rows << { sha256: sha, | |||||
| body: lines[i], | |||||
| created_at: now, | |||||
| updated_at: now } | |||||
| end | |||||
| if missing_rows.any? | |||||
| WikiLine.upsert_all(missing_rows, unique_by: :index_wiki_lines_on_sha256) | |||||
| id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h | |||||
| end | |||||
| id_by_sha | |||||
| end | |||||
| end | |||||
| end | |||||