diff --git a/README.md b/README.md index b676953..b59c846 100644 --- a/README.md +++ b/README.md @@ -187,10 +187,16 @@ Example from `Modsvaskr/Tests/Statuses_interior_cell.json` When selecting your game, you can ask Modsvaskr to easily install SKSE in it. This is useful when SKSE gets upgraded. By selecting the `Install SKSE64` item in the game menu, Modsvaskr will download and install the latest SKSE version in your game folder. -This feature cannot be used if run under Mod Organizer, as it needs to modify the game base directory. Just run `Modsvaskr.cmd` outside of Mod Organizer to use this feature. +If you run this feature under Mod Organizer, don't forget to save the modified files from the Overwrite mode in a dedicated ModOrganizer mod to not lose them. ![Installing SKSE](docs/example_install_skse.png) +### Using Modsvaskr with ModOrganizer + +Modsvaskr has a menu dedicated to ModOrganizer, where you can see the various mods and plugins configured in Modsvaskr. + +![ModOrganizer mods](docs/example_mod_organizer_mods.png) + ## Troubleshooting Logs of execution of Modsvaskr are stored in the current folder: `Modsvaskr.log`. diff --git a/bin/modsvaskr b/bin/modsvaskr index ebad272..4d6b64a 100755 --- a/bin/modsvaskr +++ b/bin/modsvaskr @@ -1,11 +1,12 @@ #!/usr/bin/env ruby -# require 'mod_organizer' +require 'English' require 'modsvaskr/config' require 'modsvaskr/ui' begin Modsvaskr::Ui.new(config: Modsvaskr::Config.new('./modsvaskr.yaml')).run rescue + puts "An exception has occurred: #{$ERROR_INFO}\n#{$ERROR_INFO.backtrace.join("\n")}" puts 'Press Enter to exit.' $stdin.gets end diff --git a/docs/example_mod_organizer_mods.png b/docs/example_mod_organizer_mods.png new file mode 100644 index 0000000..9dcf9ed Binary files /dev/null and b/docs/example_mod_organizer_mods.png differ diff --git a/lib/modsvaskr/config.rb b/lib/modsvaskr/config.rb index edd9aed..25fdf62 100644 --- a/lib/modsvaskr/config.rb +++ b/lib/modsvaskr/config.rb @@ -1,3 +1,4 @@ +require 'mod_organizer' require 'yaml' require 'modsvaskr/game' require 'modsvaskr/xedit' @@ -73,6 +74,19 @@ def no_prompt @config['no_prompt'] || false end + # Return the ModOrganizer instance, if configured. + # + # Result:: + # * ModOrganizer or nil: The ModOrganizer instance, or nil if none + def mod_organizer + return nil unless @config['mod_organizer'] + + ModOrganizer.new( + @config['mod_organizer']['installation_dir'], + instance_name: @config['mod_organizer']['instance_name'] + ) + end + end end diff --git a/lib/modsvaskr/ui.rb b/lib/modsvaskr/ui.rb index ba28d87..d3e581c 100644 --- a/lib/modsvaskr/ui.rb +++ b/lib/modsvaskr/ui.rb @@ -1,5 +1,6 @@ require 'English' require 'curses_menu' +require 'launchy' require 'modsvaskr/logger' require 'modsvaskr/tests_runner' require 'modsvaskr/run_cmd' @@ -20,8 +21,12 @@ class Ui def initialize(config:) log "Launch Modsvaskr UI v#{Modsvaskr::VERSION} - Logs in #{Logger.log_file}" @config = config + @mod_organizer = @config.mod_organizer end + # Time that should be before any file time of mods (used for sorting) + TIME_BEGIN = Time.parse('2000-01-01') + # Run the UI def run last_modsvaskr_version = nil @@ -203,6 +208,102 @@ def run end end end + unless @mod_organizer.nil? + main_menu.item 'Mods Organizer' do + CursesMenu.new( + 'Modsvaskr - Stronghold of Mods > Mods Organizer', + key_presses: + ) do |mo_menu| + # Show status + mo_menu.item 'Run Mod Organizer' do + @mod_organizer.run + end + mo_menu.item "#{@mod_organizer.mod_names.size} mods (#{@mod_organizer.mod_names.select { |mod_name| @mod_organizer.mod(name: mod_name).enabled? }.size} enabled)" do + CursesMenu.new( + 'Modsvaskr - Stronghold of Mods > Mods Organizer > Mods', + key_presses: + ) do |mods_menu| + mod_names = @mod_organizer.mods_list + idx_size = (mod_names.size - 1).to_s.size + mod_names.each.with_index do |mod_name, idx| + mod = @mod_organizer.mod(name: mod_name) + # need_update = false + mods_menu.item( + proc do + sections = { + enabled_cell: { + text: mod.enabled? ? 'X' : ' ', + begin_with: '[', + end_with: ']', + fixed_size: 3 + }, + idx_cell: { + text: idx.to_s, + fixed_size: idx_size + }, + name_cell: { + text: mod_name, + color_pair: CursesMenu::COLORS_GREEN, + fixed_size: 64 + }, + categories_cell: { + text: mod.categories.map { |category| "[#{category}]" }.join(' '), + fixed_size: 16 + }, + esps_cell: { + text: "#{mod.plugins.size} plugins", + fixed_size: 10 + } + } + mod.sources.sort_by { |source| source.download.nil? ? TIME_BEGIN : source.download.downloaded_date }.each.with_index do |source, src_idx| + source_name = src_idx.zero? ? '' : '+ ' + source_name << + case source.type + when :nexus_mods + "Nexus Mod #{source.nexus_mod_id}/" + + if source.download&.nexus_file_name + source.download&.nexus_file_name + elsif source.file_name + source.file_name + else + '' + end + when :unknown + source.file_name || '' + else + raise "Unknown source type: #{source.type} for #{mod.name}" + end + sections["source_#{src_idx}".to_sym] = { + text: source_name, + color_pair: CursesMenu::COLORS_WHITE + } + end + CursesMenu::CursesRow.new(sections) + end, + actions: proc do + actions = {} + # TODO: Use downloads' URL instead of mod's URL as it is very often more accurate + if mod.url + actions['v'] = { + name: 'Visit', + execute: proc { Launchy.open(mod.url) } + } + end + actions + end + ) + end + mods_menu.item 'Back' do + :menu_exit + end + end + end + mo_menu.item 'Back' do + :menu_exit + end + end + end + end main_menu.item 'See logs' do CursesMenu.new( 'Modsvaskr - Stronghold of Mods > Logs', @@ -227,6 +328,7 @@ def run log 'Close Modsvaskr UI' end + end end diff --git a/modsvaskr.gemspec b/modsvaskr.gemspec index 2d261af..96a3fa8 100644 --- a/modsvaskr.gemspec +++ b/modsvaskr.gemspec @@ -18,9 +18,11 @@ Gem::Specification.new do |spec| spec.executables << File.basename(exec_name) end - spec.add_dependency 'curses_menu', '~> 0.0' + spec.add_dependency 'curses_menu', '~> 0.2' spec.add_dependency 'elder_scrolls_plugin', '~> 0.0' - spec.add_dependency 'nokogiri', '~> 1.13' + spec.add_dependency 'launchy', '~> 2.5' + spec.add_dependency 'mod_organizer', '~> 1.0' + spec.add_dependency 'nokogiri', '~> 1.14' # Development dependencies (tests, build) # Test framework @@ -30,7 +32,7 @@ Gem::Specification.new do |spec| # Automatic semantic releasing spec.add_development_dependency 'sem_ver_components', '~> 0.3' # Lint checker - spec.add_development_dependency 'rubocop', '~> 1.41' + spec.add_development_dependency 'rubocop', '~> 1.47' # Lint checker for rspec - spec.add_development_dependency 'rubocop-rspec', '~> 2.16' + spec.add_development_dependency 'rubocop-rspec', '~> 2.18' end diff --git a/spec/modsvaskr_test/helpers.rb b/spec/modsvaskr_test/helpers.rb index 940881e..299ebee 100644 --- a/spec/modsvaskr_test/helpers.rb +++ b/spec/modsvaskr_test/helpers.rb @@ -4,6 +4,7 @@ require 'yaml' require 'modsvaskr/config' require 'modsvaskr/ui' +require 'modsvaskr_test/mocked_mod_organizer/mod_organizer' module ModsvaskrTest @@ -127,6 +128,25 @@ def run_and_discover(csv: '', run: false, expect_tests: {}, mock_tests_statuses: end end + # Run Modsvaskr with a ModOrganizer test configuration setup and a set of mocked mods. + # Use a test MO instance on a test path. + # + # Parameters:: + # * *mods* (Hash>): Mocked mods information [default: {}] + # * *keys* (Array): List of keys to be entered once in the menu [default: []] + def run_modsvaskr_with_mo(mods: {}, keys: []) + expect(::ModOrganizer).to receive(:new).with('/path/to/mo', hash_including(instance_name: 'TestMOInstance')).and_return(MockedModOrganizer::ModOrganizer.new(mods:)) + run_modsvaskr( + config: { + 'mod_organizer' => { + 'installation_dir' => '/path/to/mo', + 'instance_name' => 'TestMOInstance' + } + }, + keys: + ) + end + # Expect logs to include a given line # # Parameters:: @@ -189,9 +209,9 @@ def expect_menu_item_to_include(menu_item_idx, line, menu_idx: @menu_index) EO_ERROR_MESSAGE end if line.is_a?(Regexp) - expect(ModsvaskrTest.screenshots[menu_idx][3 + menu_item_idx].match(line)).to be(true), error_msg_proc + expect(ModsvaskrTest.screenshots[menu_idx][3 + menu_item_idx]).to match(line), error_msg_proc else - expect(ModsvaskrTest.screenshots[menu_idx][3 + menu_item_idx].include?(line)).to be(true), error_msg_proc + expect(ModsvaskrTest.screenshots[menu_idx][3 + menu_item_idx]).to include(line), error_msg_proc end end diff --git a/spec/modsvaskr_test/mocked_mod_organizer/download.rb b/spec/modsvaskr_test/mocked_mod_organizer/download.rb new file mode 100644 index 0000000..6242877 --- /dev/null +++ b/spec/modsvaskr_test/mocked_mod_organizer/download.rb @@ -0,0 +1,35 @@ +module ModsvaskrTest + + module MockedModOrganizer + + class Download + + attr_reader :downloaded_file_path, :downloaded_date, :nexus_file_name, :nexus_mod_id, :nexus_file_id + + # Constructor + # + # Parameters:: + # * *downloaded_file_path* (String or nil): Full downloaded file path, or nil if does not exist [default: nil] + # * *downloaded_date* (Time or nil): Download date of this source, or nil if no file [default: Time.now] + # * *nexus_file_name* (String): Original file name from NexusMods [default: 'nexus_mods_file.7z'] + # * *nexus_mod_id* (Integer): Mod ID from NexusMods [default: 42] + # * *nexus_file_id* (Integer): File ID from NexusMods [default: 666] + def initialize( + downloaded_file_path: nil, + downloaded_date: Time.now, + nexus_file_name: 'nexus_mods_file.7z', + nexus_mod_id: 42, + nexus_file_id: 666 + ) + @downloaded_file_path = downloaded_file_path + @downloaded_date = downloaded_date + @nexus_file_name = nexus_file_name + @nexus_mod_id = nexus_mod_id + @nexus_file_id = nexus_file_id + end + + end + + end + +end diff --git a/spec/modsvaskr_test/mocked_mod_organizer/mod.rb b/spec/modsvaskr_test/mocked_mod_organizer/mod.rb new file mode 100644 index 0000000..f739bd4 --- /dev/null +++ b/spec/modsvaskr_test/mocked_mod_organizer/mod.rb @@ -0,0 +1,47 @@ +require 'modsvaskr_test/mocked_mod_organizer/source' + +module ModsvaskrTest + + module MockedModOrganizer + + class Mod + + attr_reader :categories, :plugins, :url + + # Constructor + # + # Parameters:: + # * *enabled* (Boolean): Is the mod enabled? [default: true] + # * *categories* (Array): List of this mod's categories [default: []] + # * *plugins* (Array): List of this mod's plugins [default: []] + # * *sources* (Array< Hash >): List of this mod's sources [default: []] + # * *url* (String): This mod's URL [default: 'https://my_test_mod.com'] + def initialize(enabled: true, categories: [], plugins: [], sources: [], url: 'https://my_test_mod.com') + @enabled = enabled + @categories = categories + @plugins = plugins + @sources = sources + @url = url + end + + # Is the mod enabled? + # + # Result:: + # * Boolean: Is the mod enabled? + def enabled? + @enabled + end + + # Return the list of sources this mod belongs to + # + # Result:: + # * Array: List of source information + def sources + @sources.map { |source| Source.new(**source) } + end + + end + + end + +end diff --git a/spec/modsvaskr_test/mocked_mod_organizer/mod_organizer.rb b/spec/modsvaskr_test/mocked_mod_organizer/mod_organizer.rb new file mode 100644 index 0000000..a9e7dd9 --- /dev/null +++ b/spec/modsvaskr_test/mocked_mod_organizer/mod_organizer.rb @@ -0,0 +1,57 @@ +require 'modsvaskr_test/mocked_mod_organizer/mod' + +module ModsvaskrTest + + module MockedModOrganizer + + class ModOrganizer + + attr_reader :run_called, :mods_list + + # Constructor + # + # Parameters:: + # * *mods* (Hash>): List of mods to mock, per mod name [default: {}] + # * *enabled* (Boolean): Is the mod enabled? [default: true] + # * *categories* (Array): List of this mod's categories [default: []] + # * *plugins* (Array): List of this mod's plugins [default: []] + # * *sources* (Array< Hash >): List of this mod's sources [default: []] + # * *mods_list* (Array): The ordered list of mod names [default: mods.keys] + # * *categories* (Hash): Categories to return, or nil for default (built from mods' categories) [default: nil] + def initialize( + mods: {}, + mods_list: mods.keys + ) + @mods = mods + @mods_list = mods_list + @run_called = false + end + + # Return mod_names + # + # Result:: + # * Array: Mod names + def mod_names + @mods.keys + end + + # Return a mod of a given name + # + # Parameters:: + # * *name* (String): Mod namd + # Result:: + # * MockedMod: The mocked mod + def mod(name:) + Mod.new(**@mods[name]) + end + + # Run ModOrganizer + def run + @run_called = true + end + + end + + end + +end diff --git a/spec/modsvaskr_test/mocked_mod_organizer/source.rb b/spec/modsvaskr_test/mocked_mod_organizer/source.rb new file mode 100644 index 0000000..413fca1 --- /dev/null +++ b/spec/modsvaskr_test/mocked_mod_organizer/source.rb @@ -0,0 +1,45 @@ +require 'modsvaskr_test/mocked_mod_organizer/download' + +module ModsvaskrTest + + module MockedModOrganizer + + class Source + + attr_reader :nexus_mod_id, :nexus_file_id, :file_name, :type + + # Constructor + # + # Parameters:: + # * *type* (Symbol): Source type [default: :unknown] + # * *nexus_mod_id* (Integer): Corresponding Nexus mod id [default: 42] + # * *nexus_file_id* (Integer): Corresponding Nexus mod file id [default: 666] + # * *file_name* (String or nil): File name that provided content to this mod [default: 'test_source_file.7z'] + # * *download* (Hash or nil): The download info [default: nil] + def initialize( + type: :unknown, + nexus_mod_id: 42, + nexus_file_id: 666, + file_name: 'test_source_file.7z', + download: nil + ) + @type = type + @nexus_mod_id = nexus_mod_id + @nexus_file_id = nexus_file_id + @file_name = file_name + @download = download + end + + # Get the download info corresponding to this source, or nil if none. + # + # Result:: + # * Download or nil: Download info, or nil if none + def download + @download.nil? ? nil : Download.new(**@download) + end + + end + + end + +end diff --git a/spec/tests/mod_organizer_menu_spec.rb b/spec/tests/mod_organizer_menu_spec.rb new file mode 100644 index 0000000..ef2bc66 --- /dev/null +++ b/spec/tests/mod_organizer_menu_spec.rb @@ -0,0 +1,228 @@ +require 'modsvaskr_test/mocked_mod_organizer/mod_organizer' + +describe 'Mod Organizer menu' do + + before do + # Register the key sequence getting to the desired menu + entering_menu_keys %w[KEY_ENTER] + exiting_menu_keys %w[KEY_ESCAPE] + menu_index_to_test 1 + end + + it 'displays the ModOrganizer menu' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => {}, + 'TestMod2' => { enabled: false }, + 'TestMod3' => {} + } + ) + expect_menu_items_to_include('Run Mod Organizer') + expect_menu_items_to_include('3 mods (2 enabled)') + end + + it 'runs ModOrganizer' do + mocked_mo = ModsvaskrTest::MockedModOrganizer::ModOrganizer.new + expect(ModOrganizer).to receive(:new).with('/path/to/mo', hash_including(instance_name: 'TestMOInstance')).and_return(mocked_mo) + run_modsvaskr( + config: { + 'mod_organizer' => { + 'installation_dir' => '/path/to/mo', + 'instance_name' => 'TestMOInstance' + } + }, + keys: %w[KEY_ENTER] + ) + expect(mocked_mo.run_called).to be(true) + end + + describe 'in the mods list menu' do + + before do + # Register the key sequence getting to the desired menu + entering_menu_keys %w[KEY_ENTER KEY_DOWN KEY_ENTER] + exiting_menu_keys %w[KEY_ESCAPE KEY_ESCAPE] + menu_index_to_test 2 + end + + it 'displays the ordered list of mods' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => {}, + 'TestMod2' => { enabled: false }, + 'TestMod3' => {} + } + ) + expect_menu_item_to_include(0, /^\[X\] 0 TestMod1/) + expect_menu_item_to_include(1, /^\[ \] 1 TestMod2/) + expect_menu_item_to_include(2, /^\[X\] 2 TestMod3/) + end + + it 'displays mod categories' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + categories: %w[Cat1 Cat2] + } + } + ) + expect_menu_item_to_include(0, '[Cat1] [Cat2]') + end + + it 'displays the mod\'s number of plugins' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + plugins: %w[plugin1.esp plugin2.esp] + } + } + ) + expect_menu_item_to_include(0, '2 plugins') + end + + it 'displays NexusMod\'s sources having downloads, sorted by download date' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + sources: [ + { + type: :nexus_mods, + nexus_mod_id: 42, + download: { + nexus_file_name: 'mod42_file.7z', + downloaded_date: Time.parse('2023-01-05') + } + }, + { + type: :nexus_mods, + nexus_mod_id: 43, + download: { + nexus_file_name: 'mod43_file.7z', + downloaded_date: Time.parse('2023-01-04') + } + } + ] + } + } + ) + expect_menu_item_to_include(0, 'Nexus Mod 43/mod43_file.7z + Nexus Mod 42/mod42_file.7z') + end + + it 'displays NexusMod\'s sources missing downloads' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + sources: [ + { + type: :nexus_mods, + nexus_mod_id: 42, + file_name: 'mod42_local_file.7z', + download: nil + } + ] + } + } + ) + expect_menu_item_to_include(0, 'Nexus Mod 42/mod42_local_file.7z') + end + + it 'displays NexusMod\'s sources missing downloads and local files' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + sources: [ + { + type: :nexus_mods, + nexus_mod_id: 42, + file_name: nil, + download: nil + } + ] + } + } + ) + expect_menu_item_to_include(0, 'Nexus Mod 42/') + end + + it 'displays unknown sources having files' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + sources: [ + { + type: :unknown, + file_name: 'mod_local_file.7z' + } + ] + } + } + ) + expect_menu_item_to_include(0, 'mod_local_file.7z') + end + + it 'displays unknown sources missing files' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + sources: [ + { + type: :unknown, + file_name: nil + } + ] + } + } + ) + expect_menu_item_to_include(0, '') + end + + it 'displays mixed sources' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + sources: [ + { + type: :nexus_mods, + nexus_mod_id: 42, + download: { + nexus_file_name: 'mod42_file.7z', + downloaded_date: Time.parse('2023-01-05') + } + }, + { + type: :unknown, + file_name: 'mod_local_file.7z' + } + ] + } + } + ) + expect_menu_item_to_include(0, 'mod_local_file.7z + Nexus Mod 42/mod42_file.7z') + end + + it 'displays no source' do + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + sources: [] + } + } + ) + expect_menu_item_to_include(0, /TestMod1\s+0 plugins/) + end + + it 'visits the mod\'s URL' do + expect(Launchy).to receive(:open).with('https://test_mod1_url.com') + run_modsvaskr_with_mo( + mods: { + 'TestMod1' => { + url: 'https://test_mod1_url.com' + } + }, + keys: %w[v] + ) + end + + end + +end