mod_organizer 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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