class DocumentRevisionsController < ApplicationController before_action :set_document, only: [:index, :show, :destroy, :diff, :restore] before_action :set_document_revision, only: [:show, :edit, :update, :destroy, :diff, :restore] # GET /document_revisions def index @document_revisions = @document.document_revisions.order('created_at DESC').paginate(page: params[:page], per_page: 10) end # GET /document_revisions/1 def show end # GET /document_revisions/1/diff def diff require 'diffy' require 'nokogiri' # Find the previous revision for comparison previous_revision = @document.document_revisions .where("created_at < ?", @document_revision.created_at) .order(created_at: :desc) .first if previous_revision # Strip HTML and get clean text previous_html = previous_revision.body.to_s current_html = @document_revision.body.to_s previous_text = strip_and_clean_html(previous_html) current_text = strip_and_clean_html(current_html) # For very large documents, truncate to avoid memory issues max_length = 50_000 # ~10k words if previous_text.length > max_length || current_text.length > max_length diff_html = generate_author_friendly_diff(previous_text[0..max_length], current_text[0..max_length], truncated: true) else diff_html = generate_author_friendly_diff(previous_text, current_text) end render html: diff_html.html_safe else # This is the first revision, show a friendly message diff_html = generate_first_revision_view(strip_and_clean_html(@document_revision.body.to_s)) render html: diff_html.html_safe end end # POST /document_revisions/1/restore def restore # First, create a backup revision of the current document state current_revision = @document.document_revisions.create!( title: @document.title, body: @document.body, synopsis: @document.synopsis, universe_id: @document.universe_id, notes_text: @document.notes_text, cached_word_count: @document.cached_word_count || 0 ) # Then restore the selected revision's content to the document @document.update!( title: @document_revision.title, body: @document_revision.body, synopsis: @document_revision.synopsis, notes_text: @document_revision.notes_text, cached_word_count: @document_revision.cached_word_count ) redirect_to edit_document_path(@document), notice: "Document successfully restored to revision from #{@document_revision.created_at.strftime('%B %d, %Y at %I:%M %p')}. Your previous version has been saved as a new revision." rescue => e Rails.logger.error "Failed to restore revision #{@document_revision.id} for document #{@document.id}: #{e.message}" redirect_to document_document_revisions_path(@document), alert: 'Failed to restore revision. Please try again.' end def strip_and_clean_html(html_text) # Convert HTML to clean text, preserving paragraph breaks doc = Nokogiri::HTML(html_text) # Remove script and style elements doc.css('script, style').remove # Convert block elements to preserve structure # Add double newlines after paragraphs for clear separation doc.css('p').each { |p| p.after("\n\n") } doc.css('div').each { |div| div.after("\n") } doc.css('br').each { |br| br.replace("\n") } doc.css('h1, h2, h3, h4, h5, h6').each { |h| h.after("\n\n") } doc.css('blockquote').each { |bq| bq.after("\n\n") } # Get text and clean up whitespace while preserving paragraph structure text = doc.text text.gsub(/\r\n?/, "\n") # Normalize line endings .gsub(/[ \t]+/, ' ') # Collapse spaces and tabs (but NOT newlines) .gsub(/ *\n */, "\n") # Remove spaces around newlines .gsub(/\n{3,}/, "\n\n") # Reduce 3+ consecutive newlines to exactly 2 .gsub(/\A\n+/, '') # Remove leading newlines .gsub(/\n+\z/, '') # Remove trailing newlines .strip end def generate_author_friendly_diff(previous_text, current_text, truncated: false) # Use diffy to get the diff directly on the text, preserving original formatting # This maintains newlines and text structure as the author intended diff = Diffy::Diff.new(previous_text, current_text, context: 3) # Calculate statistics prev_words = previous_text.split.size curr_words = current_text.split.size word_diff = curr_words - prev_words # Build the HTML html = <<~HTML

Changes Summary

#{word_diff > 0 ? '+' : ''}#{word_diff} words
Previous: #{prev_words} words → Current: #{curr_words} words
#{truncated ? '
warningDocument truncated for performance
' : ''}

history Previous Version

#{format_version_column(diff, :old, previous_text, current_text)}

check_circle Current Version

#{format_version_column(diff, :new, previous_text, current_text)}
Removed text
Added text
Unchanged text
HTML html end def format_version_column(diff, version, previous_text, current_text) formatted_html = "" diff.to_s(:text).each_line do |line| next if line.start_with?('@') # Skip line numbers next if line.strip == '\\ No newline at end of file' # Safely extract content, handling edge cases if line.length > 1 content = CGI.escapeHTML(line[1..-1].chomp) # Remove diff marker and newline # Convert newlines to HTML line breaks for proper display content = content.gsub(/\n/, '
') else content = "" end # Get the diff marker (first character) marker = line[0] || ' ' if version == :old # Show removed and unchanged content for old version case marker when '-' # Removed content - show in red with strikethrough formatted_html += "

#{content}

" when '+' # Added content - skip in old version when ' ' # Context/unchanged content - show normally formatted_html += "

#{content}

" else # Handle any other cases as context formatted_html += "

#{content}

" end else # version == :new # Show added and unchanged content for new version case marker when '+' # Added content - show in green with bold formatted_html += "

#{content}

" when '-' # Removed content - skip in new version when ' ' # Context/unchanged content - show normally formatted_html += "

#{content}

" else # Handle any other cases as context formatted_html += "

#{content}

" end end end if formatted_html.empty? # If no diff content, show the actual revision content without highlighting content = version == :old ? previous_text : current_text if content.blank? "

No content

" else # Show the plain text content without diff highlighting paragraphs = content.split(/\n\n+/).map(&:strip).reject(&:empty?) paragraphs.map { |para| "

#{CGI.escapeHTML(para)}

" }.join end else formatted_html end end def generate_first_revision_view(text) word_count = text.split.size preview = text[0..500] preview += "..." if text.length > 500 <<~HTML
celebration

First Revision Created!

This is the initial version of your document.

Document Preview #{word_count} words

#{CGI.escapeHTML(preview)}

Future changes to your document will be tracked and displayed here.

HTML end # GET /document_revisions/new def new @document_revision = DocumentRevision.new end # GET /document_revisions/1/edit def edit end # POST /document_revisions def create @document_revision = DocumentRevision.new(document_revision_params) if @document_revision.save redirect_to @document_revision, notice: 'Document revision was successfully created.' else render :new end end # PATCH/PUT /document_revisions/1 def update if @document_revision.update(document_revision_params) redirect_to @document_revision, notice: 'Document revision was successfully updated.' else render :edit end end # DELETE /document_revisions/1 def destroy @document_revision.destroy redirect_to document_document_revisions_path(@document), notice: 'Document revision was successfully deleted.' end private def set_document @document = Document.find(params[:document_id]) end # Use callbacks to share common setup or constraints between actions. def set_document_revision @document_revision = DocumentRevision.find(params[:id]) end # Only allow a trusted parameter "white list" through. def document_revision_params params.fetch(:document_revision, {}) end end