Merge branch 'feature/188' of https://git.miteruzo.com/miteruzo/btrc-hub into feature/188 #188 Merge branch 'main' into feature/188 #188 #188 #188 #188 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/195feature/201
| @@ -1,8 +1,8 @@ | |||
| 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 | |||
| def show | |||
| @@ -31,21 +31,25 @@ class WikiPagesController < ApplicationController | |||
| def diff | |||
| id = params[:id] | |||
| from = params[:from] | |||
| return head :bad_request if id.blank? | |||
| from = params[:from].presence | |||
| to = params[:to].presence | |||
| 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) | |||
| 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 head :unprocessable_entity | |||
| end | |||
| diffs = Diff::LCS.sdiff(wiki_page_from.body, wiki_page_to.body) | |||
| 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 ?| | |||
| when ?! | |||
| [{ type: 'removed', content: change.old_element }, | |||
| { type: 'added', content: change.new_element }] | |||
| when ?+ | |||
| @@ -55,23 +59,32 @@ class WikiPagesController < ApplicationController | |||
| end | |||
| }.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 | |||
| def create | |||
| 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 | |||
| 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 | |||
| return head :unprocessable_entity if title.blank? || body.blank? | |||
| page = WikiPage.new(title:, created_user: current_user, updated_user: current_user) | |||
| if page.save | |||
| message = params[:message].presence | |||
| Wiki::Commit.content!(page:, body:, created_user: current_user, message:) | |||
| render json: page, status: :created | |||
| else | |||
| render json: { errors: wiki_page.errors.full_messages }, status: :unprocessable_entity | |||
| render json: { errors: page.errors.full_messages }, | |||
| status: :unprocessable_entity | |||
| end | |||
| end | |||
| @@ -79,16 +92,24 @@ class WikiPagesController < ApplicationController | |||
| return head :unauthorized unless current_user | |||
| 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? | |||
| 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]) | |||
| base_revision_id = page.current_revision.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:, | |||
| base_revision_id:) | |||
| head :ok | |||
| end | |||
| @@ -97,55 +118,73 @@ class WikiPagesController < ApplicationController | |||
| title = params[:title]&.strip | |||
| 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) | |||
| end | |||
| 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 | |||
| end | |||
| 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 | |||
| 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) | |||
| def render_wiki_page_or_404 wiki_page | |||
| return head :not_found unless wiki_page | |||
| return render json: page.as_json.merge(body:, revision_id:, pred:, succ:) | |||
| end | |||
| rev = page.current_revision | |||
| unless rev | |||
| return render json: page.as_json.merge(body: nil, revision_id: nil, pred: nil, succ: nil) | |||
| end | |||
| wiki_page.sha = params[:version].presence | |||
| 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 | |||
| @@ -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 }) | |||
| find_by!(sha256: sha) | |||
| end | |||
| end | |||
| @@ -1,80 +1,50 @@ | |||
| require 'gollum-lib' | |||
| require 'set' | |||
| 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 | |||
| belongs_to :created_user, class_name: 'User' | |||
| belongs_to :updated_user, class_name: 'User' | |||
| has_many :redirected_from_revisions, | |||
| class_name: 'WikiRevision', | |||
| foreign_key: :redirect_page_id, | |||
| dependent: :nullify | |||
| 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 | |||
| 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 | |||
| 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 | |||
| 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 | |||
| @@ -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) | |||
| id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h | |||
| end | |||
| id_by_sha | |||
| end | |||
| end | |||
| end | |||
| @@ -0,0 +1,5 @@ | |||
| class RemoveTagFromWikiPages < ActiveRecord::Migration[7.0] | |||
| def change | |||
| remove_reference :wiki_pages, :tag, if_exists: true | |||
| end | |||
| end | |||
| @@ -0,0 +1,11 @@ | |||
| class CreateWikiLines < ActiveRecord::Migration[7.0] | |||
| def change | |||
| create_table :wiki_lines do |t| | |||
| t.string :sha256, null: false, limit: 64 | |||
| t.text :body, null: false | |||
| t.timestamps | |||
| end | |||
| add_index :wiki_lines, :sha256, unique: true | |||
| end | |||
| end | |||
| @@ -0,0 +1,19 @@ | |||
| class CreateWikiRevisions < ActiveRecord::Migration[7.0] | |||
| def change | |||
| create_table :wiki_revisions do |t| | |||
| t.references :wiki_page, null: false, foreign_key: true | |||
| t.references :base_revision, foreign_key: { to_table: :wiki_revisions } | |||
| t.references :created_user, null: false, foreign_key: { to_table: :users } | |||
| t.integer :kind, null: false, default: 0 # 0: content, 1: redirect | |||
| t.references :redirect_page, foreign_key: { to_table: :wiki_pages } | |||
| t.string :message | |||
| t.integer :lines_count, null: false, default: 0 | |||
| t.string :tree_sha256, limit: 64 | |||
| t.timestamps | |||
| end | |||
| add_index :wiki_revisions, :tree_sha256 | |||
| add_index :wiki_revisions, [:wiki_page_id, :id] | |||
| add_index :wiki_revisions, :kind | |||
| end | |||
| end | |||
| @@ -0,0 +1,12 @@ | |||
| class CreateWikiRevisionLines < ActiveRecord::Migration[7.0] | |||
| def change | |||
| create_table :wiki_revision_lines do |t| | |||
| t.references :wiki_revision, null: false, foreign_key: true | |||
| t.integer :position, null: false | |||
| t.references :wiki_line, null: false, foreign_key: true | |||
| end | |||
| add_index :wiki_revision_lines, [:wiki_revision_id, :position], unique: true | |||
| add_index :wiki_revision_lines, [:wiki_revision_id, :wiki_line_id] | |||
| end | |||
| end | |||
| @@ -10,7 +10,7 @@ | |||
| # | |||
| # It's strongly recommended that you check this file into your version control system. | |||
| ActiveRecord::Schema[8.0].define(version: 2025_12_10_123200) do | |||
| ActiveRecord::Schema[8.0].define(version: 2025_12_29_022100) do | |||
| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "name", null: false | |||
| t.string "record_type", null: false | |||
| @@ -167,18 +167,54 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_123200) do | |||
| t.datetime "updated_at", null: false | |||
| end | |||
| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "sha256", limit: 64, null: false | |||
| t.text "body", null: false | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.index ["sha256"], name: "index_wiki_lines_on_sha256", unique: true | |||
| end | |||
| create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "title", null: false | |||
| t.bigint "tag_id" | |||
| t.bigint "created_user_id", null: false | |||
| t.bigint "updated_user_id", null: false | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" | |||
| t.index ["tag_id"], name: "index_wiki_pages_on_tag_id" | |||
| t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" | |||
| end | |||
| create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "wiki_revision_id", null: false | |||
| t.integer "position", null: false | |||
| t.bigint "wiki_line_id", null: false | |||
| t.index ["wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_line_id" | |||
| t.index ["wiki_revision_id", "position"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_position", unique: true | |||
| t.index ["wiki_revision_id", "wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_wiki_line_id" | |||
| t.index ["wiki_revision_id"], name: "index_wiki_revision_lines_on_wiki_revision_id" | |||
| end | |||
| create_table "wiki_revisions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "wiki_page_id", null: false | |||
| t.bigint "base_revision_id" | |||
| t.bigint "created_user_id", null: false | |||
| t.integer "kind", default: 0, null: false | |||
| t.bigint "redirect_page_id" | |||
| t.string "message" | |||
| t.integer "lines_count", default: 0, null: false | |||
| t.string "tree_sha256", limit: 64 | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.index ["base_revision_id"], name: "index_wiki_revisions_on_base_revision_id" | |||
| t.index ["created_user_id"], name: "index_wiki_revisions_on_created_user_id" | |||
| t.index ["kind"], name: "index_wiki_revisions_on_kind" | |||
| t.index ["redirect_page_id"], name: "index_wiki_revisions_on_redirect_page_id" | |||
| t.index ["tree_sha256"], name: "index_wiki_revisions_on_tree_sha256" | |||
| t.index ["wiki_page_id", "id"], name: "index_wiki_revisions_on_wiki_page_id_and_id" | |||
| t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" | |||
| end | |||
| add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" | |||
| add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" | |||
| add_foreign_key "nico_tag_relations", "tags" | |||
| @@ -201,7 +237,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_123200) do | |||
| add_foreign_key "user_ips", "users" | |||
| add_foreign_key "user_post_views", "posts" | |||
| add_foreign_key "user_post_views", "users" | |||
| add_foreign_key "wiki_pages", "tags" | |||
| add_foreign_key "wiki_pages", "users", column: "created_user_id" | |||
| add_foreign_key "wiki_pages", "users", column: "updated_user_id" | |||
| add_foreign_key "wiki_revision_lines", "wiki_lines" | |||
| add_foreign_key "wiki_revision_lines", "wiki_revisions" | |||
| add_foreign_key "wiki_revisions", "users", column: "created_user_id" | |||
| add_foreign_key "wiki_revisions", "wiki_pages" | |||
| add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id" | |||
| add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id" | |||
| end | |||
| @@ -0,0 +1,73 @@ | |||
| namespace :wiki do | |||
| desc 'Wiki 移行' | |||
| task migrate: :environment do | |||
| require 'digest' | |||
| require 'gollum-lib' | |||
| wiki = Gollum::Wiki.new(Rails.root.join('wiki').to_s) | |||
| WikiPage.where.missing(:wiki_revisions).find_each do |wiki_page| | |||
| page = wiki.page("#{ wiki_page.id }.md") | |||
| next unless page | |||
| versions = page.versions | |||
| next if versions.blank? | |||
| base_revision_id = nil | |||
| versions.reverse_each do |version| | |||
| pg = wiki.page("#{ wiki_page.id }.md", version.id) | |||
| raw = pg&.raw_data | |||
| next unless raw | |||
| lines = raw.force_encoding('UTF-8').split("\n") | |||
| line_shas = lines.map { |l| Digest::SHA256.hexdigest(l) } | |||
| tree_sha = Digest::SHA256.hexdigest(line_shas.join(',')) | |||
| at = version.authored_date | |||
| line_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 line_id_by_sha.key?(sha) | |||
| missing_rows << { sha256: sha, | |||
| body: lines[i], | |||
| created_at: at, | |||
| updated_at: at } | |||
| end | |||
| if missing_rows.any? | |||
| WikiLine.upsert_all(missing_rows) | |||
| line_id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h | |||
| end | |||
| line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) } | |||
| ActiveRecord::Base.transaction do | |||
| wiki_page.lock! | |||
| rev = WikiRevision.create!( | |||
| wiki_page:, | |||
| base_revision_id:, | |||
| created_user_id: Integer(version.author.name) rescue 2, | |||
| kind: :content, | |||
| redirect_page_id: nil, | |||
| message: nil, | |||
| lines_count: lines.length, | |||
| tree_sha256: tree_sha, | |||
| created_at: at, | |||
| updated_at: at) | |||
| 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) | |||
| end | |||
| base_revision_id = rev.id | |||
| end | |||
| end | |||
| end | |||
| end | |||
| @@ -40,10 +40,10 @@ export default () => { | |||
| {diff | |||
| ? ( | |||
| diff.diff.map (d => ( | |||
| <span className={cn (d.type === 'added' && 'bg-green-200 dark:bg-green-800', | |||
| d.type === 'removed' && 'bg-red-200 dark:bg-red-800')}> | |||
| {d.content == '\n' ? <br/> : d.content} | |||
| </span>))) | |||
| <p className={cn (d.type === 'added' && 'bg-green-200 dark:bg-green-800', | |||
| d.type === 'removed' && 'bg-red-200 dark:bg-red-800')}> | |||
| {d.content} | |||
| </p>))) | |||
| : 'Loading...'} | |||
| </div> | |||
| </MainArea>) | |||
| @@ -41,30 +41,20 @@ export default () => { | |||
| </thead> | |||
| <tbody> | |||
| {changes.map (change => ( | |||
| <tr key={change.sha}> | |||
| <tr key={change.revisionId}> | |||
| <td> | |||
| {change.changeType === 'update' && ( | |||
| <Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.sha }`}> | |||
| {change.pred != null && ( | |||
| <Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}> | |||
| 差分 | |||
| </Link>)} | |||
| </td> | |||
| <td className="p-2"> | |||
| <Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.sha }`}> | |||
| <Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}> | |||
| {change.wikiPage.title} | |||
| </Link> | |||
| </td> | |||
| <td className="p-2"> | |||
| {(() => { | |||
| switch (change.changeType) | |||
| { | |||
| case 'create': | |||
| return '新規' | |||
| case 'update': | |||
| return '更新' | |||
| case 'delete': | |||
| return '削除' | |||
| } | |||
| }) ()} | |||
| {change.pred == null ? '新規' : '更新'} | |||
| </td> | |||
| <td className="p-2"> | |||
| <Link to={`/users/${ change.user.id }`}> | |||
| @@ -59,29 +59,33 @@ export type User = { | |||
| export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior] | |||
| export type WikiPage = { | |||
| id: number | |||
| title: string | |||
| body: string | |||
| sha: string | |||
| pred?: string | |||
| succ?: string | |||
| updatedAt?: string } | |||
| id: number | |||
| title: string | |||
| createdUserId: number | |||
| updatedUserId: number | |||
| createdAt: string | |||
| updatedAt: string | |||
| body: string | |||
| revisionId: number | |||
| pred: number | null | |||
| succ: number | null } | |||
| export type WikiPageChange = { | |||
| sha: string | |||
| pred?: string | |||
| succ?: string | |||
| wikiPage: WikiPage | |||
| user: User | |||
| changeType: string | |||
| revisionId: number | |||
| pred: number | null | |||
| succ: null | |||
| wikiPage: Pick<WikiPage, 'id' | 'title'> | |||
| user: Pick<User, 'id' | 'name'> | |||
| kind: 'content' | 'redirect' | |||
| message: string | null | |||
| timestamp: string } | |||
| export type WikiPageDiff = { | |||
| wikiPageId: number | |||
| title: string | |||
| olderSha: string | |||
| newerSha: string | |||
| diff: WikiPageDiffDiff[] } | |||
| wikiPageId: number | |||
| title: string | |||
| olderRevisionId: number | null | |||
| newerRevisionId: number | |||
| diff: WikiPageDiffDiff[] } | |||
| export type WikiPageDiffDiff = { | |||
| type: 'context' | 'added' | 'removed' | |||