diff --git a/Gemfile b/Gemfile index 6154150..b08febb 100644 --- a/Gemfile +++ b/Gemfile @@ -57,6 +57,10 @@ group :development, :test do gem 'factory_bot_rails' gem 'dotenv-rails' + + # CLI Stuff + gem 'diffy' + gem 'tty-prompt' end group :development do @@ -79,6 +83,5 @@ end gem 'contentful' gem 'rack-cors' -gem 'tty-prompt' gem 'dockerfile-rails', '>= 1.4', :group => :development diff --git a/Gemfile.lock b/Gemfile.lock index 29adafe..f25f92b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,6 +92,7 @@ GEM irb (>= 1.5.0) reline (>= 0.3.1) diff-lcs (1.5.0) + diffy (3.4.2) dockerfile-rails (1.4.1) rails domain_name (0.5.20190701) @@ -316,6 +317,7 @@ DEPENDENCIES capybara contentful debug + diffy dockerfile-rails (>= 1.4) dotenv-rails factory_bot_rails diff --git a/app/models/book.rb b/app/models/book.rb index 4525cc8..77e137e 100644 --- a/app/models/book.rb +++ b/app/models/book.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'csv' class Book < ApplicationRecord has_many :chapters, :dependent => :destroy @@ -22,4 +23,32 @@ def last_tuk def number_of_chapters_released return self.chapters.released.count end + + ## + # Create (or update) the a specific chapter via CSV + # HOW IT WORKS: + # 1. Add your CSV file to `lib/imports/#{book.sequence}/#{chapter_number}.csv` + # 2. Pass in a `chapter_number: Integer` e.g. `3` + # 3. Call `@book.import_chapter(3)`. + # EXAMPLE: + # @book = Book.find_by(:sequence => 1) + # @book.import_chapter(3) + # This will search for a CSV file at `lib/imports/1/3.csv` (or raise error) + # The CSV file should have the following columns: + # - Chapter_Number: Integer + # - Chapter_Name: String + # - Chhand_Type: String + # - Tuk: String + # - Pauri_Number: Integer + # - Tuk_Number: Integer + # - Pauri_Translation_EN: String | NULL + # - Tuk_Translation_EN: String | NULL + # - Footnotes: String | NULL + # - Extended_Ref: String | NULL + # - Assigned_Singh: String | NULL + # - Extended_Meaning: String | NULL + ## + def import_chapter(chapter_number) + ChapterImporterService.new(self, chapter_number).call + end end diff --git a/app/models/chapter.rb b/app/models/chapter.rb index 06b54ef..b2b0c6d 100644 --- a/app/models/chapter.rb +++ b/app/models/chapter.rb @@ -17,4 +17,19 @@ class Chapter < ApplicationRecord else scope :released, -> { all } end + + def csv_rows + file_path = "lib/imports/#{self.book.sequence}/#{self.number}.csv" + + unless File.exist?(file_path) + Rails.logger.debug "CSV file #{file_path} not found. " + Pastel.new.red.on_bright_white.bold("Are you sure you added it to #{file_path}?") + raise "CSV file #{file_path} not found. " + end + + rows = [] + CSV.foreach(file_path, :headers => true) do |row| + rows << row + end + return rows + end end diff --git a/app/services/chapter_importer_service.rb b/app/services/chapter_importer_service.rb new file mode 100644 index 0000000..7fae66e --- /dev/null +++ b/app/services/chapter_importer_service.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +class ChapterImporterService + attr_reader :book, :chapter_number, :pastel, :prompt, :chapter + + def initialize(book, chapter_number) + @book = book + @chapter_number = chapter_number + @pastel = Pastel.new + @prompt = TTY::Prompt.new + end + + def call + ensure_chapter_exists! + ActiveRecord::Base.transaction do + each_csv_row do |row| + process_row(row) + end + end + end + + private + + def each_csv_row(&) + blob = chapter.csv_rows + blob.each(&) + end + + def ensure_chapter_exists! + @chapter = book.chapters.find_by(:number => chapter_number) + raise "Chapter not found: #{chapter_number}" unless @chapter + end + + def process_row(row) + validate_and_update_chapter_title(row) + process_pauri(row) + process_tuk(row) + create_tuk_translation(row) + create_pauri_translation(row) + end + + def validate_and_update_chapter_title(row) + chapter_name = row['Chapter_Name'].try(:strip) + return if chapter.title == chapter_name + + message = "The name in Book #{book.sequence}, Chapter #{chapter_number} is " + + pastel.bold('presently') + + " '#{chapter.title}'. The CSV says '#{chapter_name}'." + prompt.say(message, :color => :yellow) + answer = prompt.yes?("Do you want to continue and update this title to '#{chapter_name}'?") + raise 'Aborted by user' unless answer + + chapter.update(:title => chapter_name) + Rails.logger.debug pastel.green("✓ Chapter #{chapter_number}'s title updated to '#{chapter_name}'") + end + + def process_pauri(row) + pauri_number = row['Pauri_Number'].to_i + @pauri = chapter.pauris.find_by(:number => pauri_number) + + raise "Pauri not found: #{pauri_number}" if @pauri.nil? + end + + def process_tuk(row) + tuk = row['Tuk'].try(:strip) + tuk_number = row['Tuk_Number'].to_i + @tuk = @pauri.tuks.find_by(:sequence => tuk_number) + + raise "Tuk #{tuk_number} not found: #{tuk}" if @tuk.nil? + + return if @tuk.original_content == tuk + + diff = Diffy::Diff.new(@tuk.original_content, tuk, :include_diff_info => true).to_s(:color) + Rails.logger.debug diff + + choices = [ + "Keep the NEW one from the CSV (update the original) :: #{tuk}", + pastel.red("Keep the original :: #{@tuk.original_content}") + ] + + selected_choice = prompt.select('Choose an option:', choices) + case selected_choice + when choices[0] + @tuk.update(:original_content => tuk) + Rails.logger.debug pastel.green("✓ `Tuk` `original_content` updated to #{tuk} - For #{@pauri.number}.#{tuk_number}") + when choices[1] + Rails.logger.debug pastel.red("x Keeping the original `Tuk` `original_content` - For #{@pauri.number}.#{tuk_number}") + end + end + + def create_tuk_translation(row) + tuk_translation_en = row['Tuk_Translation_EN'].try(:strip) || row['Translation_EN'].try(:strip) + return if tuk_translation_en.blank? + + # If there's a Pauri translation and a Tuk translation, ask what to do + if @pauri.translation.present? + choices = [ + 'Continue with the `TukTranslation` and KEEP the `PauriTranslation`, too!', + 'Destroy the existing `PauriTranslation`, and continue with only the `TukTranslation`', + pastel.red('Abort') + ] + selected_choice = prompt.select('Choose an option:', choices) + + case selected_choice + when choices[0] + upsert_tuk_translation(tuk_translation_en, row['Assigned_Singh'].try(:strip)) + when choices[1] + upsert_tuk_translation(tuk_translation_en, row['Assigned_Singh'].try(:strip)) + @pauri.translation.destroy + when choices[2] + raise 'Aborted by user' + end + else + upsert_tuk_translation(tuk_translation_en, row['Assigned_Singh'].try(:strip)) + end + end + + def upsert_tuk_translation(translation, translator) + @tuk_translation = @tuk.translation || TukTranslation.new(:tuk_id => @tuk.id) + @tuk_translation.update(:en_translation => translation, :en_translator => translator) + Rails.logger.debug pastel.green("✓ `TukTranslation` created or updated for Tuk #{translation} - Pauri # #{@pauri.number}") + end + + def create_pauri_translation(row) + pauri_translation_en = row['Pauri_Translation_EN'].try(:strip) + return if pauri_translation_en.blank? + + pauri_translation = @pauri.translation || PauriTranslation.new(:pauri_id => @pauri.id) + pauri_translation.update(:en_translation => pauri_translation_en, :en_translator => row['Assigned_Singh'].try(:strip)) + Rails.logger.debug pastel.green("✓ `PauriTranslation` created or updated for Pauri # #{@pauri.number}") + end +end diff --git a/config/application.rb b/config/application.rb index 0bd5a52..c9c34b2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -27,7 +27,7 @@ class Application < Rails::Application config.middleware.insert_before 0, Rack::Cors do allow do - origins 'https://spg.dev', /\A.*\.netlify\.app\z/ + origins 'https://spg.dev', /\A.*\.netlify\.app\z/, 'localhost:3000' resource '*', headers: :any, diff --git a/spec/models/book_spec.rb b/spec/models/book_spec.rb new file mode 100644 index 0000000..bb93be6 --- /dev/null +++ b/spec/models/book_spec.rb @@ -0,0 +1,123 @@ +# rubocop:disable RSpec/NestedGroups, RSpec/MultipleExpectations +# frozen_string_literal: true + +require 'rails_helper' +require 'csv' + +RSpec.describe Book do + let(:book) { create(:book) } + + describe '#import_chapter' do + context 'when the `chapter.number` is invalid' do + it 'raises an error if the `chapter_number` does not exist in `book`' do + expect { book.import_chapter(99) }.to raise_error(RuntimeError, /Chapter not found: 99/) + end + + it 'raises an error if the CSV does not exist' do + # Create the `Chapter` row only! Not the CSV. + create(:chapter, :book => book, :number => 100) + expect { book.import_chapter(100) }.to raise_error(RuntimeError, %r{CSV file lib/imports/1/100\.csv not found}) + end + end + + context 'when the `chapter_number` is valid' do + csv_content = <<~CSV + Chapter_Number,Chapter_Name,Chhand_Type ,Tuk,Pauri_Number,Tuk_Number,Pauri_Translation_EN,Translation_EN ,Footnotes,Custom_Footnotes,Extended_Ref ,Assigned_Singh,Status,Extended_Meaning + CSV + + csv_rows = CSV.parse(csv_content, :headers => true) + let(:chapter) { create(:chapter, :book => book) } + + it 'does not raise an error' do + file_path = "lib/imports/#{book.sequence}/#{chapter.number}.csv" + + allow(File).to receive(:exist?).with(file_path).and_return(true) + allow(CSV).to receive(:foreach).with(file_path, :headers => true).and_return(csv_rows) + + expect { book.import_chapter(chapter.number) }.not_to raise_error + end + end + + context 'when given valid input and CSV' do + let(:chapter_number) { 99 } + let(:chapter_title) { 'ਇਸ਼੍ਟ ਦੇਵ-ਸ਼੍ਰੀ ਅਕਾਲ ਪੁਰਖ-ਮੰਗਲ' } + let(:csv_content) do + <<~CSV + Chapter_Number,Chapter_Name,Chhand_Type ,Tuk,Pauri_Number,Tuk_Number,Pauri_Translation_EN,Translation_EN ,Footnotes,Custom_Footnotes,Extended_Ref ,Assigned_Singh,Status,Extended_Meaning + 99,ਇਸ਼੍ਟ ਦੇਵ-ਸ਼੍ਰੀ ਅਕਾਲ ਪੁਰਖ-ਮੰਗਲ,ਦੋਹਰਾ,"ਤੀਨੋ ਕਾਲ ਅਲਿਪਤ ਰਹਿ, ਖੋਜੈਂ ਜਾਂਹਿ ਪ੍ਰਬੀਨ",1,1,,,,,,,, + 99,ਇਸ਼੍ਟ ਦੇਵ-ਸ਼੍ਰੀ ਅਕਾਲ ਪੁਰਖ-ਮੰਗਲ,ਦੋਹਰਾ,"ਬੀਨਤਿ ਸਚਿਦਾਨੰਦ ਤ੍ਰੈ, ਜਾਨਹਿਂ ਮਰਮ ਰਤੀ ਨ",1,2,,,,,,,, + CSV + end + + let(:chapter) { create(:chapter, :number => chapter_number, :book => book, :title => chapter_title) } + let(:chhand_type) { create(:chhand_type, :name => 'ਦੋਹਰਾ') } + let(:chhand) { create(:chhand, :chhand_type => chhand_type, :chapter => chapter) } + let(:pauri) { create(:pauri, :chapter => chapter, :chhand => chhand, :number => 1) } + let(:prompt) { instance_double(TTY::Prompt) } + + before do + # Initialize mocks for TTY::Prompt + allow(TTY::Prompt).to receive(:new).and_return(prompt) + allow(prompt).to receive(:say) + + # Associations for the chapter - This one reflects out mock `csv_content` + create(:tuk, :pauri => pauri, :chapter => chapter, :original_content => 'ਤੀਨੋ ਕਾਲ ਅਲਿਪਤ ਰਹਿ, ਖੋਜੈਂ ਜਾਂਹਿ ਪ੍ਰਬੀਨ', :sequence => 1) + create(:tuk, :pauri => pauri, :chapter => chapter, :original_content => 'ਬੀਨਤਿ ਸਚਿਦਾਨੰਦ ਤ੍ਰੈ, ਜਾਨਹਿਂ ਮਰਮ ਰਤੀ ਨ', :sequence => 2) + + # Mocking the CSV data + allow(book.chapters).to receive(:find_by).with(:number => chapter_number).and_return(chapter) + allow(chapter).to receive(:csv_rows).and_return(CSV.parse(csv_content, :headers => true)) + end + + context 'when prompted to update `chapter.title`' do + it 'does not prompt user if the `chapter.title` is unchanged' do + allow(prompt).to receive(:yes?) + book.import_chapter(chapter_number) + expect(prompt).not_to have_received(:yes?) + + expect(chapter.reload.title).to eq(chapter_title) + end + + it 'updates the `chapter.title` if user confirms' do + # Change the `chapter.title` so it is different than the one in CSV + chapter.update(:title => 'Different title') + + allow(prompt).to receive(:yes?).and_return(true) + book.import_chapter(chapter_number) + + expect(prompt).to have_received(:yes?).with("Do you want to continue and update this title to '#{chapter_title}'?") + expect(chapter.reload.title).to eq(chapter_title) + end + + it 'aborts and does not update the chapter title if user declines' do + # Change the `chapter.title` so it is different than the one in CSV + chapter.update(:title => 'Something Else - Suraj Suraj Suraj') + allow(prompt).to receive(:yes?).and_return(false) + expect { book.import_chapter(chapter_number) }.to raise_error(RuntimeError, 'Aborted by user') + end + end + + context 'when `chapter` associations do not exist' do + it 'raises an error when `pauri` is nil' do + pauri.destroy + expect { book.import_chapter(chapter_number) }.to raise_error(StandardError, /Pauri not found/) + end + + it 'raises an error when `tuks` are nil' do + Tuk.destroy_all + expect { book.import_chapter(chapter_number) }.to raise_error(StandardError, /Tuk 1 not found/) + end + + it 'raises an error when 2nd `tuk` is missing' do + chapter.tuks.second.destroy + expect { book.import_chapter(chapter_number) }.to raise_error(StandardError, /Tuk 2 not found/) + end + end + + # TODO: Write tests for Translations, Footnotes, etc. + # TODO: and write tests for the TTY stuff + end + end +end + +# rubocop:enable RSpec/NestedGroups, RSpec/MultipleExpectations diff --git a/spec/models/chapter_spec.rb b/spec/models/chapter_spec.rb index d2f91ae..4e55905 100644 --- a/spec/models/chapter_spec.rb +++ b/spec/models/chapter_spec.rb @@ -27,7 +27,7 @@ it 'cannot save when `book_id` does not belong to a real book' do expect do - Chapter.create!({ :book_id => 1, :number => 2, :title => 'Mangal' }) + Chapter.create!({ :book_id => 99, :number => 2, :title => 'Mangal' }) end.to raise_error(ActiveRecord::RecordInvalid) end end