Browse Source

#188

feature/188
みてるぞ 1 week ago
parent
commit
2832d0a6ca
6 changed files with 321 additions and 122 deletions
  1. +93
    -62
      backend/app/controllers/wiki_pages_controller.rb
  2. +15
    -0
      backend/app/models/wiki_line.rb
  3. +28
    -60
      backend/app/models/wiki_page.rb
  4. +55
    -0
      backend/app/models/wiki_revision.rb
  5. +8
    -0
      backend/app/models/wiki_revision_line.rb
  6. +122
    -0
      backend/app/services/wiki/commit.rb

+ 93
- 62
backend/app/controllers/wiki_pages_controller.rb View File

@@ -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

+ 15
- 0
backend/app/models/wiki_line.rb View File

@@ -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

+ 28
- 60
backend/app/models/wiki_page.rb View File

@@ -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

+ 55
- 0
backend/app/models/wiki_revision.rb View File

@@ -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

+ 8
- 0
backend/app/models/wiki_revision_line.rb View File

@@ -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

+ 122
- 0
backend/app/services/wiki/commit.rb View File

@@ -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

Loading…
Cancel
Save