mod_organizer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8d69da7c062ed92dec29a95678101c8dee6b251c32232a2168e1d945f02cb709
4
+ data.tar.gz: 7231f3afddc4126af0210a2c9ac8f95dc3fd520bd803e753b5a30b46e8fadd4f
5
+ SHA512:
6
+ metadata.gz: a141e18fcba85d165e87a1c878503cfe7cbb7023feca45cba1afb9dec333fa70bf2d2f1452a1d06c9827ea557af2bcbf9f49970da15713e029fa481015a43039
7
+ data.tar.gz: f9c7e766e18fe3e652772b0eb617e2dccbdfd8ecbd0d6859b78c4c7e00400fb3b810b074d96aa6bbb295cab246ae82dec5b812bcea7b370e3bafdf3e7bf31f0d
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # [v0.0.1](https://github.com/Muriel-Salvan/mod_organizer/compare/...v0.0.1) (2023-01-07 18:10:56)
2
+
3
+ ### Patches
4
+
5
+ * [Added releaserc](https://github.com/Muriel-Salvan/mod_organizer/commit/4d802611296415ec0b5e4141d41874590664873c)
6
+ * [Plugins extension are case sensitive](https://github.com/Muriel-Salvan/mod_organizer/commit/1448f7cd127b9c10878cc9b7472617c60c65a2b2)
7
+
8
+ # 0.0.1
9
+
10
+ * Initial version
11
+ * Get mods list with plugin details and sources
12
+ * Get downloads information with NexusMods details
data/LICENSE.md ADDED
@@ -0,0 +1,31 @@
1
+
2
+ The license stated herein is a copy of the BSD License (modified on July 1999).
3
+ The AUTHOR mentionned below refers to the list of people involved in the
4
+ creation and modification of any file included in the delivered package.
5
+ This list is found in the file named AUTHORS.
6
+ The AUTHORS and LICENSE files have to be included in any release of software
7
+ embedding source code of this package, or using it as a derivative software.
8
+
9
+ Copyright (c) 2019 - 2023 Muriel Salvan (muriel@x-aeon.com)
10
+
11
+ Redistribution and use in source and binary forms, with or without
12
+ modification, are permitted provided that the following conditions are met:
13
+
14
+ 1. Redistributions of source code must retain the above copyright notice,
15
+ this list of conditions and the following disclaimer.
16
+ 2. Redistributions in binary form must reproduce the above copyright notice,
17
+ this list of conditions and the following disclaimer in the documentation
18
+ and/or other materials provided with the distribution.
19
+ 3. The name of the author may not be used to endorse or promote products
20
+ derived from this software without specific prior written permission.
21
+
22
+ THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED
23
+ WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
24
+ MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
25
+ EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
26
+ EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
27
+ OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
28
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
29
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
30
+ IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
31
+ OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # Mod Organizer
2
+
3
+ Simple Ruby API letting you handle an instance of [Mod Organizer](https://www.nexusmods.com/skyrimspecialedition/mods/6194).
4
+
5
+ ## Install
6
+
7
+ Via gem
8
+
9
+ ``` bash
10
+ $ gem install mod_organizer
11
+ ```
12
+
13
+ Via a Gemfile
14
+
15
+ ``` ruby
16
+ $ gem 'mod_organizer'
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ``` ruby
22
+ require 'mod_organizer'
23
+
24
+ mod_organizer = ModOrganizer.new('C:/Program Files/Mod Organizer')
25
+ mod_organizer.mod_names.each do |mod_name|
26
+ puts "Mod #{mod_name} has #{mod_organizer.mod(mod_name:).plugins.size} plugins"
27
+ end
28
+ ```
29
+
30
+ In case your ModOrganizer instance is not installed as portable, then you have to specify the instance name:
31
+ ``` ruby
32
+ mod_organizer = ModOrganizer.new('C:/Program Files/Mod Organizer', instance_name: 'MyInstance')
33
+ ```
34
+
35
+ ## Change log
36
+
37
+ Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
38
+
39
+ ## Testing
40
+
41
+ Automated tests are done using rspec.
42
+
43
+ Do execute them, first install development dependencies:
44
+
45
+ ```bash
46
+ bundle install
47
+ ```
48
+
49
+ Then execute rspec
50
+
51
+ ```bash
52
+ bundle exec rspec
53
+ ```
54
+
55
+ ## Contributing
56
+
57
+ Any contribution is welcome:
58
+ * Fork the github project and create pull requests.
59
+ * Report bugs by creating tickets.
60
+ * Suggest improvements and new features by creating tickets.
61
+
62
+ ## Credits
63
+
64
+ - [Muriel Salvan][link-author]
65
+
66
+ ## License
67
+
68
+ The BSD License. Please see [License File](LICENSE.md) for more information.
@@ -0,0 +1,57 @@
1
+ 1|Animations|4,51|0
2
+ 52|Poses|29|1
3
+ 2|Armour|5,54|0
4
+ 53|Power Armor|53|2
5
+ 3|Audio|33,35,106|0
6
+ 38|Music|34,61|0
7
+ 39|Voice|36,107|0
8
+ 5|Clothing|9,60|0
9
+ 41|Jewelry|102|5
10
+ 42|Backpacks|49|5
11
+ 6|Collectables|10,92|0
12
+ 28|Companions|11,66,96|0
13
+ 7|Creatures, Mounts, & Vehicles|12,65,83,101|0
14
+ 8|Factions|16,25|0
15
+ 9|Gameplay|15,24|0
16
+ 27|Combat|77|9
17
+ 43|Crafting|50,100|9
18
+ 48|Overhauls|24,79|9
19
+ 49|Perks|27|9
20
+ 54|Radio|31|9
21
+ 55|Shouts|104|9
22
+ 22|Skills & Levelling|46,73|9
23
+ 58|Weather & Lighting|56|9
24
+ 44|Equipment|44|43
25
+ 45|Home/Settlement|45|43
26
+ 10|Body, Face, & Hair|17,26|0
27
+ 39|Tattoos|57|10
28
+ 40|Character Presets|58|0
29
+ 11|Items|27,85|0
30
+ 32|Mercantile|23,69|0
31
+ 37|Ammo|3|11
32
+ 19|Weapons|41,55|11
33
+ 36|Weapon & Armour Sets|42|11
34
+ 23|Player Homes|28,67|0
35
+ 25|Castles & Mansions|68|23
36
+ 51|Settlements|48|23
37
+ 12|Locations|20,21,22,30,47,70,88,89,90,91|0
38
+ 4|Cities|53|12
39
+ 31|Landscape Changes|58|0
40
+ 29|Environment|14,74|0
41
+ 30|Immersion|51,78|0
42
+ 20|Magic|75,93,94|0
43
+ 21|Models & Textures|19,29|0
44
+ 33|Modders resources|18,82|0
45
+ 13|NPCs|22,33,99|0
46
+ 24|Bugfixes|6,95|0
47
+ 14|Patches|25,84|24
48
+ 35|Utilities|38,39|0
49
+ 26|Cheats|8|0
50
+ 15|Quests|30,35|0
51
+ 16|Races & Classes|34|0
52
+ 34|Stealth|76|0
53
+ 17|UI|37,42|0
54
+ 18|Visuals|40,62|0
55
+ 50|Pip-Boy|52|18
56
+ 46|Shader Presets|13,97,105|0
57
+ 47|Miscellaneous|2,28|0
@@ -0,0 +1,76 @@
1
+ require 'memoist'
2
+
3
+ class ModOrganizer
4
+
5
+ # Object storing information about a downloaded file and giving a lazy API on it to save resources
6
+ class Download
7
+
8
+ extend Memoist
9
+
10
+ # Constructor
11
+ #
12
+ # Parameters::
13
+ # * *mod_organizer* (ModOrganizer): The Mod Organizer instance this mod has been instantiated for
14
+ # * *file_name* (String): The file name for this download
15
+ def initialize(mod_organizer, file_name)
16
+ @mod_organizer = mod_organizer
17
+ @file_name = file_name
18
+ end
19
+
20
+ # Full downloaded file path
21
+ #
22
+ # Result::
23
+ # * String or nil: Full downloaded file path, or nil if does not exist
24
+ def downloaded_file_path
25
+ full_path = "#{@mod_organizer.downloads_dir}/#{@file_name}"
26
+ File.exist?(full_path) ? full_path : nil
27
+ end
28
+
29
+ # Download date of the source
30
+ #
31
+ # Result::
32
+ # * Time or nil: Download date of this source, or nil if no file
33
+ def downloaded_date
34
+ file_path = downloaded_file_path
35
+ file_path ? File.mtime(file_path).utc : nil
36
+ end
37
+
38
+ # NexusMods file name
39
+ #
40
+ # Result::
41
+ # * String:: Original file name from NexusMods
42
+ def nexus_file_name
43
+ meta_ini['General']['name']
44
+ end
45
+
46
+ # NexusMods mod ID
47
+ #
48
+ # Result::
49
+ # * Integer:: Mod ID from NexusMods
50
+ def nexus_mod_id
51
+ meta_ini['General']['modID']
52
+ end
53
+
54
+ # NexusMods file ID
55
+ #
56
+ # Result::
57
+ # * Integer:: File ID from NexusMods
58
+ def nexus_file_id
59
+ meta_ini['General']['fileID']
60
+ end
61
+
62
+ private
63
+
64
+ # Return the mod's meta ini file.
65
+ # Cache it for performance.
66
+ #
67
+ # Result::
68
+ # * Hash: The mod's meta ini content
69
+ def meta_ini
70
+ IniFile.load("#{@mod_organizer.downloads_dir}/#{@file_name}.meta")
71
+ end
72
+ memoize :meta_ini
73
+
74
+ end
75
+
76
+ end
@@ -0,0 +1,104 @@
1
+ require 'inifile'
2
+ require 'memoist'
3
+ require 'mod_organizer/source'
4
+ require 'mod_organizer/utils'
5
+
6
+ class ModOrganizer
7
+
8
+ # Object storing information about a mod a giving a lazy API on it to save resources (API calls, IO reading, files parsing, esp/bsa exploration...)
9
+ # A mod is an entry in ModOrganizer list.
10
+ class Mod
11
+
12
+ extend Memoist
13
+
14
+ # String: Mod's name
15
+ attr_reader :name
16
+
17
+ # Constructor
18
+ #
19
+ # Parameters::
20
+ # * *mod_organizer* (ModOrganizer): The Mod Organizer instance this mod has been instantiated for
21
+ # * *mod_path* (String): Directory containing the mod information
22
+ def initialize(mod_organizer, mod_path)
23
+ @mod_organizer = mod_organizer
24
+ @path = mod_path
25
+ @name = File.basename(@path)
26
+ end
27
+
28
+ # Is this mod enabled in Mod Organizer?
29
+ #
30
+ # Result::
31
+ # * Boolean: Is this mod enabled in Mod Organizer?
32
+ def enabled?
33
+ @mod_organizer.enabled_mods.include?(@name)
34
+ end
35
+
36
+ # Return the list of ModOrganizer categories this mod belongs to
37
+ #
38
+ # Result::
39
+ # * Array<String>: List of MO categories
40
+ def categories
41
+ meta_ini['General']['category'].to_s.split(',').map do |cat_id|
42
+ cat_int = Integer(cat_id)
43
+ cat_int.positive? ? @mod_organizer.categories[cat_int] : nil
44
+ end.compact
45
+ end
46
+
47
+ # Return the list of plugins this mod is containing.
48
+ # Cache it.
49
+ #
50
+ # Result::
51
+ # * Array<String>: List of plugins belonging to this mod
52
+ def plugins
53
+ (
54
+ files_glob("#{@path}/*.esm") +
55
+ files_glob("#{@path}/*.esp") +
56
+ files_glob("#{@path}/*.esl")
57
+ ).map { |file_name| File.basename(file_name).downcase }
58
+ end
59
+ memoize :plugins
60
+
61
+ # Return the list of sources this mod belongs to
62
+ #
63
+ # Result::
64
+ # * Array<Source>: List of source information
65
+ def sources
66
+ nbr_sources = meta_ini['installedFiles']['size'] || 1
67
+ nbr_sources.times.map do |install_idx|
68
+ Source.new(
69
+ @mod_organizer,
70
+ nexus_mod_id: meta_ini['installedFiles']["#{install_idx + 1}\\modid"],
71
+ nexus_file_id: meta_ini['installedFiles']["#{install_idx + 1}\\fileid"],
72
+ file_name: install_idx == nbr_sources - 1 ? meta_ini['General']['installationFile'] : nil
73
+ )
74
+ end
75
+ end
76
+ memoize :sources
77
+
78
+ # The mod's URL
79
+ #
80
+ # Result::
81
+ # * String or nil: The mod's URL, or nil if none
82
+ def url
83
+ ini_url = meta_ini['General']['url']
84
+ ini_url.nil? || ini_url.empty? ? nil : ini_url
85
+ end
86
+
87
+ private
88
+
89
+ include Utils
90
+
91
+ # Return the mod's meta ini file.
92
+ # Cache it for performance.
93
+ #
94
+ # Result::
95
+ # * Hash: The mod's meta ini content (can be empty of no meta)
96
+ def meta_ini
97
+ ini_file = "#{@path}/meta.ini"
98
+ File.exist?(ini_file) ? IniFile.load(ini_file) : IniFile.new(content: '')
99
+ end
100
+ memoize :meta_ini
101
+
102
+ end
103
+
104
+ end
@@ -0,0 +1,55 @@
1
+ class ModOrganizer
2
+
3
+ # Object storing information about the source of a mod and giving a lazy API on it to save resources
4
+ # A mod source is something (a file from NexusMods, a manual download...) that has provided content for the mod.
5
+ class Source
6
+
7
+ # Integer or nil: NexusMods mod ID, or nil if none
8
+ attr_reader :nexus_mod_id
9
+
10
+ # Integer or nil: NexusMods file ID, or nil if none
11
+ attr_reader :nexus_file_id
12
+
13
+ # String or nil: File name for this source, or nil if none
14
+ attr_reader :file_name
15
+
16
+ # Constructor
17
+ #
18
+ # Parameters::
19
+ # * *mod_organizer* (ModOrganizer): The Mod Organizer instance this mod has been instantiated for
20
+ # * *nexus_mod_id* (Integer): Corresponding Nexus mod id, or 0 or nil if none
21
+ # * *nexus_file_id* (Integer): Corresponding Nexus mod file id, or 0 or nil if none
22
+ # * *file_name* (String): File name that provided content to this mod, or nil if none
23
+ def initialize(
24
+ mod_organizer,
25
+ nexus_mod_id:,
26
+ nexus_file_id:,
27
+ file_name:
28
+ )
29
+ @mod_organizer = mod_organizer
30
+ @nexus_mod_id = nexus_mod_id.nil? || nexus_mod_id.zero? ? nil : nexus_mod_id
31
+ @nexus_file_id = nexus_file_id.nil? || nexus_file_id.zero? ? nil : nexus_file_id
32
+ @file_name = file_name
33
+ end
34
+
35
+ # The type of source
36
+ #
37
+ # Result::
38
+ # * Symbol: The source's type. Can be:
39
+ # * nexus_mods: Content downloaded from NexusMods
40
+ # * unknown: Unknown source
41
+ def type
42
+ @nexus_mod_id ? :nexus_mods : :unknown
43
+ end
44
+
45
+ # Get the download info corresponding to this source, or nil if none.
46
+ #
47
+ # Result::
48
+ # * Download or nil: Download info, or nil if none
49
+ def download
50
+ @file_name ? @mod_organizer.download(file_name: @file_name) : nil
51
+ end
52
+
53
+ end
54
+
55
+ end
@@ -0,0 +1,23 @@
1
+ class ModOrganizer
2
+
3
+ # Module giving some helpers to various ModOrganizer classes
4
+ module Utils
5
+
6
+ # Return all files matching a glob.
7
+ # Handle special characters correctly.
8
+ # Don't return . and ..
9
+ #
10
+ # Parameters::
11
+ # * *glob* (String): The glob
12
+ # Result::
13
+ # * Array<String>: The list of files matching the glob
14
+ def files_glob(glob)
15
+ Dir.glob(glob.gsub('[', '\\[').gsub(']', '\\]'), File::FNM_DOTMATCH).select do |file|
16
+ basename = File.basename(file)
17
+ basename != '.' && basename != '..'
18
+ end
19
+ end
20
+
21
+ end
22
+
23
+ end
@@ -0,0 +1,5 @@
1
+ class ModOrganizer
2
+
3
+ VERSION = '1.0.0'
4
+
5
+ end
@@ -0,0 +1,148 @@
1
+ require 'csv'
2
+ require 'fileutils'
3
+ require 'json'
4
+ require 'logger'
5
+ require 'memoist'
6
+ require 'tmpdir'
7
+ require 'inifile'
8
+ require 'mod_organizer/download'
9
+ require 'mod_organizer/mod'
10
+ require 'mod_organizer/utils'
11
+
12
+ # Handle a ModOrganizer installation: mods, esps, load order.
13
+ # No concept of Merges.
14
+ # No concept of what is actually present in the game directory (except already installed masters/plugins and load order).
15
+ class ModOrganizer
16
+
17
+ extend Memoist
18
+
19
+ # String: The game path
20
+ attr_reader :game_path
21
+
22
+ # String: The downloads dir
23
+ attr_reader :downloads_dir
24
+
25
+ # Constructor
26
+ #
27
+ # Parameters::
28
+ # * *mo_dir* (String): Mod Organizer installation directory
29
+ # * *instance_name* (String or nil): Mod Organizer instance name, or nil in case of a portable installation. [default: nil]
30
+ # * *logger* (Logger): The logger to be used for log messages [default: Logger.new(STDOUT)]
31
+ def initialize(
32
+ mo_dir,
33
+ instance_name: nil,
34
+ logger: Logger.new($stdout)
35
+ )
36
+ @mo_dir = mo_dir.gsub('\\', '/')
37
+ @mo_instance_dir = instance_name.nil? ? @mo_dir : "#{ENV.fetch('LOCALAPPDATA')}/ModOrganizer/#{instance_name}"
38
+ @logger = logger
39
+ # Read MO ini file
40
+ mo_ini_file = "#{@mo_instance_dir}/ModOrganizer.ini"
41
+ raise "Missing ModOrganizer configuration file #{mo_ini_file}" unless File.exist?(mo_ini_file)
42
+
43
+ mo_ini = IniFile.load(mo_ini_file)
44
+ @selected_profile = mo_ini['General']['selected_profile']
45
+ @selected_profile = ::Regexp.last_match(1) if @selected_profile =~ /^@ByteArray\((.+)\)$/
46
+ @game_path = mo_ini['General']['gamePath'].gsub('\\', '/')
47
+ @game_path = ::Regexp.last_match(1) if @game_path =~ /^@ByteArray\((.+)\)$/
48
+ @profiles_dir = (mo_ini['Settings']['profiles_directory'] || "#{@mo_instance_dir}/profiles").gsub('\\', '/')
49
+ @mods_dir = (mo_ini['Settings']['mod_directory'] || "#{@mo_instance_dir}/mods").gsub('\\', '/')
50
+ @overwrite_dir = (mo_ini['Settings']['overwrite_directory'] || "#{@mo_instance_dir}/overwrite").gsub('\\', '/')
51
+ @downloads_dir = (mo_ini['Settings']['download_directory'] || "#{@mo_instance_dir}/downloads").gsub('\\', '/')
52
+ @logger.debug "Selected profile: #{@selected_profile}"
53
+ @logger.debug "Mods directory: #{@mods_dir}"
54
+ @logger.debug "Downloads directory: #{@downloads_dir}"
55
+ @logger.debug "Game path: #{@game_path}"
56
+ end
57
+
58
+ # Run an instance of ModOrganizer
59
+ def run
60
+ Dir.chdir(@mo_dir) do
61
+ system 'ModOrganizer.exe'
62
+ end
63
+ end
64
+
65
+ # Get the list of mod names
66
+ #
67
+ # Result::
68
+ # * Array<String>: List of mods
69
+ def mod_names
70
+ files_glob("#{@mods_dir}/*").map { |mod_dir| File.directory?(mod_dir) ? File.basename(mod_dir) : nil }.compact
71
+ end
72
+ memoize :mod_names
73
+
74
+ # Retrieve a mod
75
+ #
76
+ # Parameters::
77
+ # * *name* (String): The mod name
78
+ # Result::
79
+ # * Mod or nil: The mod, or nil if the mod is unknown
80
+ def mod(name:)
81
+ mod_dir = "#{@mods_dir}/#{name}"
82
+ File.exist?(mod_dir) ? Mod.new(self, mod_dir) : nil
83
+ end
84
+ memoize :mod
85
+
86
+ # Get the ordered MO mods list, sorted from the first being loaded to the last (so opposite from the internal MO file)
87
+ #
88
+ # Result::
89
+ # * Array<String>: Sorted list of mod names
90
+ def mods_list
91
+ modlist.map { |mod_name, _enabled| mod_name }
92
+ end
93
+ memoize :mods_list
94
+
95
+ # Return the list of enabled mods
96
+ #
97
+ # Result::
98
+ # * Array<String>: Enabled mods
99
+ def enabled_mods
100
+ cached_enabled_mods = []
101
+ modlist.each do |(mod_name, mod_enabled)|
102
+ cached_enabled_mods << mod_name if mod_enabled
103
+ end
104
+ cached_enabled_mods
105
+ end
106
+ memoize :enabled_mods
107
+
108
+ # Get the categories
109
+ #
110
+ # Result::
111
+ # * Hash<Integer, String>: For each category ID, the corresponding category name
112
+ def categories
113
+ categories_file = "#{@mo_dir}/categories.dat"
114
+ categories_file = "#{__dir__}/default_categories.dat" unless File.exist?(categories_file)
115
+ CSV.read(categories_file, col_sep: '|').to_h { |cat_id, title, _nexus_ids, _parent_id| [cat_id.to_i, title] }
116
+ end
117
+ memoize :categories
118
+
119
+ # Return a downloaded info of file if it exists
120
+ #
121
+ # Parameters::
122
+ # * *file_name* (String): The base file name for which we want the download info
123
+ # Result::
124
+ # * Download: The downloaded information
125
+ def download(file_name:)
126
+ downloaded_file = "#{@downloads_dir}/#{file_name}"
127
+ File.exist?(downloaded_file) ? Download.new(self, file_name) : nil
128
+ end
129
+ memoize :download
130
+
131
+ private
132
+
133
+ include Utils
134
+
135
+ # Get the ordered MO mods list, sorted from the first being loaded to the last (so opposite from the internal MO file)
136
+ #
137
+ # Result::
138
+ # * Array<[String, Boolean]>: Sorted list of mod names with their enabled flag
139
+ def modlist
140
+ cached_mods_list = []
141
+ File.read("#{@profiles_dir}/#{@selected_profile}/modlist.txt").split("\n").each do |line|
142
+ cached_mods_list << [::Regexp.last_match(2), ::Regexp.last_match(1) == '+'] if line =~ /^([+-])(.+)$/
143
+ end
144
+ cached_mods_list.reverse
145
+ end
146
+ memoize :modlist
147
+
148
+ end