factorix 0.5.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/completion/_factorix.bash +202 -0
- data/completion/_factorix.fish +197 -0
- data/completion/_factorix.zsh +376 -0
- data/doc/factorix.1 +377 -0
- data/exe/factorix +20 -0
- data/lib/factorix/api/category.rb +69 -0
- data/lib/factorix/api/image.rb +35 -0
- data/lib/factorix/api/license.rb +71 -0
- data/lib/factorix/api/mod_download_api.rb +66 -0
- data/lib/factorix/api/mod_info.rb +166 -0
- data/lib/factorix/api/mod_management_api.rb +237 -0
- data/lib/factorix/api/mod_portal_api.rb +204 -0
- data/lib/factorix/api/release.rb +49 -0
- data/lib/factorix/api/tag.rb +95 -0
- data/lib/factorix/api.rb +7 -0
- data/lib/factorix/api_credential.rb +54 -0
- data/lib/factorix/application.rb +218 -0
- data/lib/factorix/cache/file_system.rb +307 -0
- data/lib/factorix/cli/commands/backup_support.rb +46 -0
- data/lib/factorix/cli/commands/base.rb +90 -0
- data/lib/factorix/cli/commands/cache/evict.rb +180 -0
- data/lib/factorix/cli/commands/cache/stat.rb +201 -0
- data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
- data/lib/factorix/cli/commands/completion.rb +83 -0
- data/lib/factorix/cli/commands/confirmable.rb +53 -0
- data/lib/factorix/cli/commands/download_support.rb +123 -0
- data/lib/factorix/cli/commands/launch.rb +79 -0
- data/lib/factorix/cli/commands/man.rb +29 -0
- data/lib/factorix/cli/commands/mod/check.rb +99 -0
- data/lib/factorix/cli/commands/mod/disable.rb +188 -0
- data/lib/factorix/cli/commands/mod/download.rb +291 -0
- data/lib/factorix/cli/commands/mod/edit.rb +114 -0
- data/lib/factorix/cli/commands/mod/enable.rb +216 -0
- data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
- data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
- data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
- data/lib/factorix/cli/commands/mod/install.rb +443 -0
- data/lib/factorix/cli/commands/mod/list.rb +372 -0
- data/lib/factorix/cli/commands/mod/search.rb +134 -0
- data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
- data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
- data/lib/factorix/cli/commands/mod/show.rb +202 -0
- data/lib/factorix/cli/commands/mod/sync.rb +299 -0
- data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
- data/lib/factorix/cli/commands/mod/update.rb +222 -0
- data/lib/factorix/cli/commands/mod/upload.rb +90 -0
- data/lib/factorix/cli/commands/path.rb +79 -0
- data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
- data/lib/factorix/cli/commands/version.rb +25 -0
- data/lib/factorix/cli.rb +42 -0
- data/lib/factorix/dependency/edge.rb +89 -0
- data/lib/factorix/dependency/entry.rb +124 -0
- data/lib/factorix/dependency/graph/builder.rb +108 -0
- data/lib/factorix/dependency/graph.rb +210 -0
- data/lib/factorix/dependency/list.rb +244 -0
- data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
- data/lib/factorix/dependency/node.rb +60 -0
- data/lib/factorix/dependency/parser.rb +148 -0
- data/lib/factorix/dependency/validation_result.rb +138 -0
- data/lib/factorix/dependency/validator.rb +190 -0
- data/lib/factorix/errors.rb +112 -0
- data/lib/factorix/formatting.rb +56 -0
- data/lib/factorix/game_version.rb +98 -0
- data/lib/factorix/http/cache_decorator.rb +106 -0
- data/lib/factorix/http/cached_response.rb +37 -0
- data/lib/factorix/http/client.rb +187 -0
- data/lib/factorix/http/response.rb +31 -0
- data/lib/factorix/http/retry_decorator.rb +59 -0
- data/lib/factorix/http/retry_strategy.rb +80 -0
- data/lib/factorix/info_json.rb +90 -0
- data/lib/factorix/installed_mod.rb +239 -0
- data/lib/factorix/mod.rb +55 -0
- data/lib/factorix/mod_list.rb +174 -0
- data/lib/factorix/mod_settings.rb +278 -0
- data/lib/factorix/mod_state.rb +34 -0
- data/lib/factorix/mod_version.rb +99 -0
- data/lib/factorix/portal.rb +185 -0
- data/lib/factorix/progress/download_handler.rb +46 -0
- data/lib/factorix/progress/multi_presenter.rb +45 -0
- data/lib/factorix/progress/presenter.rb +67 -0
- data/lib/factorix/progress/presenter_adapter.rb +46 -0
- data/lib/factorix/progress/scan_handler.rb +33 -0
- data/lib/factorix/progress/upload_handler.rb +33 -0
- data/lib/factorix/runtime/base.rb +233 -0
- data/lib/factorix/runtime/linux.rb +32 -0
- data/lib/factorix/runtime/mac_os.rb +53 -0
- data/lib/factorix/runtime/user_configurable.rb +69 -0
- data/lib/factorix/runtime/windows.rb +85 -0
- data/lib/factorix/runtime/wsl.rb +118 -0
- data/lib/factorix/runtime.rb +32 -0
- data/lib/factorix/save_file.rb +178 -0
- data/lib/factorix/ser_des/deserializer.rb +198 -0
- data/lib/factorix/ser_des/serializer.rb +231 -0
- data/lib/factorix/ser_des/signed_integer.rb +63 -0
- data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
- data/lib/factorix/service_credential.rb +127 -0
- data/lib/factorix/transfer/downloader.rb +162 -0
- data/lib/factorix/transfer/uploader.rb +232 -0
- data/lib/factorix/version.rb +6 -0
- data/lib/factorix.rb +38 -0
- data/sig/dry/auto_inject.rbs +15 -0
- data/sig/dry/cli.rbs +19 -0
- data/sig/dry/configurable.rbs +13 -0
- data/sig/dry/core/container.rbs +17 -0
- data/sig/dry/events/publisher.rbs +22 -0
- data/sig/dry/logger.rbs +16 -0
- data/sig/factorix/api/category.rbs +15 -0
- data/sig/factorix/api/image.rbs +15 -0
- data/sig/factorix/api/license.rbs +20 -0
- data/sig/factorix/api/mod_download_api.rbs +18 -0
- data/sig/factorix/api/mod_info.rbs +67 -0
- data/sig/factorix/api/mod_management_api.rbs +25 -0
- data/sig/factorix/api/mod_portal_api.rbs +31 -0
- data/sig/factorix/api/release.rbs +27 -0
- data/sig/factorix/api/tag.rbs +15 -0
- data/sig/factorix/api.rbs +8 -0
- data/sig/factorix/api_credential.rbs +17 -0
- data/sig/factorix/application.rbs +86 -0
- data/sig/factorix/cache/file_system.rbs +35 -0
- data/sig/factorix/cli/commands/base.rbs +13 -0
- data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
- data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
- data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
- data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
- data/sig/factorix/cli/commands/confirmable.rbs +12 -0
- data/sig/factorix/cli/commands/download_support.rbs +12 -0
- data/sig/factorix/cli/commands/launch.rbs +15 -0
- data/sig/factorix/cli/commands/mod/check.rbs +18 -0
- data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/download.rbs +18 -0
- data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
- data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
- data/sig/factorix/cli/commands/mod/install.rbs +19 -0
- data/sig/factorix/cli/commands/mod/list.rbs +30 -0
- data/sig/factorix/cli/commands/mod/search.rbs +18 -0
- data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
- data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
- data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
- data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
- data/sig/factorix/cli/commands/mod/update.rbs +19 -0
- data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
- data/sig/factorix/cli/commands/path.rbs +18 -0
- data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
- data/sig/factorix/cli/commands/version.rbs +13 -0
- data/sig/factorix/cli.rbs +11 -0
- data/sig/factorix/dependency/edge.rbs +32 -0
- data/sig/factorix/dependency/entry.rbs +30 -0
- data/sig/factorix/dependency/graph/builder.rbs +17 -0
- data/sig/factorix/dependency/graph.rbs +39 -0
- data/sig/factorix/dependency/list.rbs +69 -0
- data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
- data/sig/factorix/dependency/node.rbs +24 -0
- data/sig/factorix/dependency/parser.rbs +11 -0
- data/sig/factorix/dependency/validation_result.rbs +56 -0
- data/sig/factorix/dependency/validator.rbs +13 -0
- data/sig/factorix/errors.rbs +132 -0
- data/sig/factorix/formatting.rbs +8 -0
- data/sig/factorix/game_version.rbs +24 -0
- data/sig/factorix/http/cache_decorator.rbs +64 -0
- data/sig/factorix/http/client.rbs +55 -0
- data/sig/factorix/http/response.rbs +28 -0
- data/sig/factorix/http/retry_decorator.rbs +44 -0
- data/sig/factorix/http/retry_strategy.rbs +42 -0
- data/sig/factorix/info_json.rbs +19 -0
- data/sig/factorix/installed_mod.rbs +34 -0
- data/sig/factorix/mod.rbs +20 -0
- data/sig/factorix/mod_list.rbs +44 -0
- data/sig/factorix/mod_settings.rbs +47 -0
- data/sig/factorix/mod_state.rbs +18 -0
- data/sig/factorix/mod_version.rbs +23 -0
- data/sig/factorix/portal.rbs +37 -0
- data/sig/factorix/progress/download_handler.rbs +19 -0
- data/sig/factorix/progress/multi_presenter.rbs +15 -0
- data/sig/factorix/progress/presenter.rbs +17 -0
- data/sig/factorix/progress/presenter_adapter.rbs +17 -0
- data/sig/factorix/progress/scan_handler.rbs +16 -0
- data/sig/factorix/progress/upload_handler.rbs +17 -0
- data/sig/factorix/runtime/base.rbs +45 -0
- data/sig/factorix/runtime/linux.rbs +15 -0
- data/sig/factorix/runtime/mac_os.rbs +15 -0
- data/sig/factorix/runtime/user_configurable.rbs +13 -0
- data/sig/factorix/runtime/windows.rbs +23 -0
- data/sig/factorix/runtime/wsl.rbs +19 -0
- data/sig/factorix/runtime.rbs +9 -0
- data/sig/factorix/save_file.rbs +40 -0
- data/sig/factorix/ser_des/deserializer.rbs +49 -0
- data/sig/factorix/ser_des/serializer.rbs +45 -0
- data/sig/factorix/ser_des/signed_integer.rbs +37 -0
- data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
- data/sig/factorix/service_credential.rbs +19 -0
- data/sig/factorix/transfer/downloader.rbs +15 -0
- data/sig/factorix/transfer/uploader.rbs +21 -0
- data/sig/factorix.rbs +9 -0
- data/sig/tty/progressbar.rbs +18 -0
- metadata +431 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
# Class for handling MOD settings
|
|
5
|
+
#
|
|
6
|
+
# MODSettings manages the settings from mod-settings.dat file, which contains
|
|
7
|
+
# three sections: startup, runtime-global, and runtime-per-user.
|
|
8
|
+
class MODSettings
|
|
9
|
+
# Valid section names
|
|
10
|
+
VALID_SECTIONS = %w[startup runtime-global runtime-per-user].freeze
|
|
11
|
+
public_constant :VALID_SECTIONS
|
|
12
|
+
|
|
13
|
+
# Represents a section in MOD settings
|
|
14
|
+
class Section
|
|
15
|
+
include Enumerable
|
|
16
|
+
|
|
17
|
+
# Initialize a new section with the given name
|
|
18
|
+
#
|
|
19
|
+
# @param name [String] The section name
|
|
20
|
+
# @raise [MODSettingsError] If the section name is invalid
|
|
21
|
+
def initialize(name)
|
|
22
|
+
unless VALID_SECTIONS.include?(name)
|
|
23
|
+
raise MODSettingsError, "Invalid MOD section name: #{name}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@name = name
|
|
27
|
+
@settings = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get the section name
|
|
31
|
+
#
|
|
32
|
+
# @return [String] The section name
|
|
33
|
+
attr_reader :name
|
|
34
|
+
|
|
35
|
+
# Set a setting value in this section
|
|
36
|
+
#
|
|
37
|
+
# @param key [String] The setting key
|
|
38
|
+
# @param value [Object] The setting value
|
|
39
|
+
# @return [Object] The setting value
|
|
40
|
+
def []=(key, value)
|
|
41
|
+
@settings[key] = value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get a setting value from this section
|
|
45
|
+
#
|
|
46
|
+
# @param key [String] The setting key
|
|
47
|
+
# @return [Object, nil] The setting value or nil if not found
|
|
48
|
+
def [](key) = @settings[key]
|
|
49
|
+
|
|
50
|
+
# Iterate over all settings in this section
|
|
51
|
+
#
|
|
52
|
+
# @yield [key, value] Block to be called for each setting
|
|
53
|
+
# @yieldparam key [String] The setting key
|
|
54
|
+
# @yieldparam value [Object] The setting value
|
|
55
|
+
# @return [Enumerator] If no block is given
|
|
56
|
+
def each(&)
|
|
57
|
+
return @settings.to_enum(:each) unless block_given?
|
|
58
|
+
|
|
59
|
+
@settings.each(&)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if this section has any settings
|
|
63
|
+
#
|
|
64
|
+
# @return [Boolean] True if the section has no settings
|
|
65
|
+
def empty? = @settings.empty?
|
|
66
|
+
|
|
67
|
+
# Check if a key exists in this section
|
|
68
|
+
#
|
|
69
|
+
# @param key [String] The setting key
|
|
70
|
+
# @return [Boolean] True if the key exists
|
|
71
|
+
def key?(key) = @settings.key?(key)
|
|
72
|
+
alias has_key? key?
|
|
73
|
+
alias include? key?
|
|
74
|
+
|
|
75
|
+
# Get all keys in this section
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<String>] Array of all setting keys
|
|
78
|
+
def keys = @settings.keys
|
|
79
|
+
|
|
80
|
+
# Get all values in this section
|
|
81
|
+
#
|
|
82
|
+
# @return [Array<Object>] Array of all setting values
|
|
83
|
+
def values = @settings.values
|
|
84
|
+
|
|
85
|
+
# Get the number of settings in this section
|
|
86
|
+
#
|
|
87
|
+
# @return [Integer] Number of settings
|
|
88
|
+
def size = @settings.size
|
|
89
|
+
alias length size
|
|
90
|
+
|
|
91
|
+
# Fetch a setting value with optional default or block
|
|
92
|
+
#
|
|
93
|
+
# @param key [String] The setting key
|
|
94
|
+
# @param default [Object] Default value if key doesn't exist (optional)
|
|
95
|
+
# @yield [key] Block to compute default value if key doesn't exist
|
|
96
|
+
# @yieldparam key [String] The missing key
|
|
97
|
+
# @return [Object] The setting value, default, or block result
|
|
98
|
+
# @raise [KeyError] If key doesn't exist and no default/block provided
|
|
99
|
+
def fetch(key, *, &) = @settings.fetch(key, *, &)
|
|
100
|
+
|
|
101
|
+
# Convert this section to a Hash
|
|
102
|
+
#
|
|
103
|
+
# @return [Hash<String, Object>] Hash of all settings
|
|
104
|
+
def to_h = @settings.dup
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Load MOD settings from file
|
|
108
|
+
#
|
|
109
|
+
# @param path [Pathname] Path to the MOD settings file (default: runtime.mod_settings_path)
|
|
110
|
+
# @return [MODSettings] New MODSettings instance
|
|
111
|
+
def self.load(path=Application[:runtime].mod_settings_path)
|
|
112
|
+
path.open("rb") do |io|
|
|
113
|
+
game_version, sections = load_settings_from_io(io)
|
|
114
|
+
new(game_version, sections)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Load settings from IO object
|
|
119
|
+
#
|
|
120
|
+
# @param io [IO] IO object to read from
|
|
121
|
+
# @return [Array<Factorix::GameVersion, Hash<String, Section>>] Game version and hash of sections
|
|
122
|
+
def self.load_settings_from_io(io)
|
|
123
|
+
deserializer = SerDes::Deserializer.new(io)
|
|
124
|
+
|
|
125
|
+
# 1. Read version (GameVersion)
|
|
126
|
+
game_version = deserializer.read_game_version
|
|
127
|
+
|
|
128
|
+
# 2. Skip a boolean value
|
|
129
|
+
deserializer.read_bool
|
|
130
|
+
|
|
131
|
+
# 3. Read property tree and organize into sections
|
|
132
|
+
raw_settings = deserializer.read_property_tree
|
|
133
|
+
sections = organize_into_sections(raw_settings)
|
|
134
|
+
|
|
135
|
+
# 4. Check for extra data at the end of file
|
|
136
|
+
unless deserializer.eof?
|
|
137
|
+
raise ExtraDataError, "Extra data found at the end of MOD settings file"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
[game_version, sections]
|
|
141
|
+
end
|
|
142
|
+
private_class_method :load_settings_from_io
|
|
143
|
+
|
|
144
|
+
# Organize raw settings data into appropriate sections
|
|
145
|
+
#
|
|
146
|
+
# @param raw_settings [Hash] Raw settings from deserializer
|
|
147
|
+
# @return [Hash<String, Section>] Hash of sections
|
|
148
|
+
# @raise [MODSettingsError] If an invalid section name is encountered
|
|
149
|
+
def self.organize_into_sections(raw_settings)
|
|
150
|
+
sections = {}
|
|
151
|
+
process_raw_settings(raw_settings, sections)
|
|
152
|
+
ensure_all_sections_exist(sections)
|
|
153
|
+
sections
|
|
154
|
+
end
|
|
155
|
+
private_class_method :organize_into_sections
|
|
156
|
+
|
|
157
|
+
# Process raw settings and add them to their respective sections
|
|
158
|
+
#
|
|
159
|
+
# @param raw_settings [Hash] Raw settings from deserializer
|
|
160
|
+
# @param sections [Hash<String, Section>] Hash to populate with sections
|
|
161
|
+
# @return [void]
|
|
162
|
+
# @raise [MODSettingsError] If an invalid section name is encountered
|
|
163
|
+
def self.process_raw_settings(raw_settings, sections)
|
|
164
|
+
raw_settings.each do |section_name, section_settings|
|
|
165
|
+
unless VALID_SECTIONS.include?(section_name)
|
|
166
|
+
raise MODSettingsError, "Invalid MOD section name: #{section_name}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
section = sections[section_name] ||= Section.new(section_name)
|
|
170
|
+
add_settings_to_section(section, section_settings)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
private_class_method :process_raw_settings
|
|
174
|
+
|
|
175
|
+
# Add the provided settings to the specified section
|
|
176
|
+
#
|
|
177
|
+
# @param section [Section] The section to add settings to
|
|
178
|
+
# @param section_settings [Hash] The settings to add
|
|
179
|
+
# @return [void]
|
|
180
|
+
def self.add_settings_to_section(section, section_settings)
|
|
181
|
+
section_settings.each do |key, value_hash|
|
|
182
|
+
# Extract the actual value from the {"value" => X} hash
|
|
183
|
+
section[key] = value_hash["value"]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
private_class_method :add_settings_to_section
|
|
187
|
+
|
|
188
|
+
# Ensure all valid sections exist in the settings, creating empty ones if necessary
|
|
189
|
+
#
|
|
190
|
+
# @param sections [Hash<String, Section>] Hash to populate with sections
|
|
191
|
+
# @return [void]
|
|
192
|
+
def self.ensure_all_sections_exist(sections)
|
|
193
|
+
VALID_SECTIONS.each do |section_name|
|
|
194
|
+
sections[section_name] ||= Section.new(section_name)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
private_class_method :ensure_all_sections_exist
|
|
198
|
+
|
|
199
|
+
# Create a new MODSettings instance
|
|
200
|
+
#
|
|
201
|
+
# @param game_version [Factorix::GameVersion] Game version
|
|
202
|
+
# @param sections [Hash<String, Section>] Hash of section name to Section objects
|
|
203
|
+
def initialize(game_version, sections)
|
|
204
|
+
@game_version = game_version
|
|
205
|
+
@sections = sections
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Get the game version
|
|
209
|
+
#
|
|
210
|
+
# @return [Factorix::GameVersion] Game version
|
|
211
|
+
attr_reader :game_version
|
|
212
|
+
|
|
213
|
+
# Get a section by name from the MOD settings
|
|
214
|
+
#
|
|
215
|
+
# @param name [String] The section name
|
|
216
|
+
# @return [Section] The section
|
|
217
|
+
# @raise [MODSettingsError] If the section name is invalid
|
|
218
|
+
# @raise [Factorix::MODSectionNotFoundError] If the section is not found
|
|
219
|
+
def [](name)
|
|
220
|
+
unless VALID_SECTIONS.include?(name)
|
|
221
|
+
raise MODSettingsError, "Invalid MOD section name: #{name}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
section = @sections[name]
|
|
225
|
+
unless section
|
|
226
|
+
raise MODSectionNotFoundError, "MOD section not found: #{name}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
section
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Iterate over all sections in the MOD settings
|
|
233
|
+
#
|
|
234
|
+
# @yield [section] Block to be called for each section
|
|
235
|
+
# @yieldparam section [Section] The section
|
|
236
|
+
# @return [Enumerator] If no block is given
|
|
237
|
+
def each_section(&)
|
|
238
|
+
return @sections.values.to_enum(:each) unless block_given?
|
|
239
|
+
|
|
240
|
+
@sections.each_value(&)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Save MOD settings to file
|
|
244
|
+
#
|
|
245
|
+
# @param path [Pathname] Path to save the MOD settings file (default: runtime.mod_settings_path)
|
|
246
|
+
# @return [void]
|
|
247
|
+
def save(path=Application[:runtime].mod_settings_path)
|
|
248
|
+
path.open("wb") do |file|
|
|
249
|
+
serializer = SerDes::Serializer.new(file)
|
|
250
|
+
|
|
251
|
+
# 1. Write version
|
|
252
|
+
serializer.write_game_version(@game_version)
|
|
253
|
+
|
|
254
|
+
# 2. Write a boolean value (seems to be always false)
|
|
255
|
+
serializer.write_bool(false)
|
|
256
|
+
|
|
257
|
+
# 3. Write property tree
|
|
258
|
+
settings_hash = build_settings_hash
|
|
259
|
+
serializer.write_property_tree(settings_hash)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Build settings hash for serialization
|
|
264
|
+
#
|
|
265
|
+
# @return [Hash] Hash of settings organized by section
|
|
266
|
+
private def build_settings_hash
|
|
267
|
+
result = {}
|
|
268
|
+
@sections.each do |section_name, section|
|
|
269
|
+
section_hash = {}
|
|
270
|
+
section.each do |key, value|
|
|
271
|
+
section_hash[key] = {"value" => value}
|
|
272
|
+
end
|
|
273
|
+
result[section_name] = section_hash unless section_hash.empty?
|
|
274
|
+
end
|
|
275
|
+
result
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
MODState = Data.define(:enabled, :version)
|
|
5
|
+
|
|
6
|
+
# Represents the state of a MOD in a MOD list
|
|
7
|
+
#
|
|
8
|
+
# This class encapsulates the enabled/disabled state and version information
|
|
9
|
+
# for a MOD as it appears in the mod-list.json file.
|
|
10
|
+
class MODState
|
|
11
|
+
# Initialize a new MODState
|
|
12
|
+
#
|
|
13
|
+
# @param enabled [Boolean] whether the MOD is enabled
|
|
14
|
+
# @param version [Factorix::MODVersion, nil] the version of the MOD (optional)
|
|
15
|
+
# @return [void]
|
|
16
|
+
#
|
|
17
|
+
# @example Creating a MODState
|
|
18
|
+
# state = Factorix::MODState[enabled: true]
|
|
19
|
+
# version = Factorix::MODVersion.from_string("1.2.3")
|
|
20
|
+
# state = Factorix::MODState[enabled: false, version: version]
|
|
21
|
+
def initialize(enabled:, version: nil) = super
|
|
22
|
+
|
|
23
|
+
# @!attribute [r] enabled
|
|
24
|
+
# @return [Boolean] whether the MOD is enabled
|
|
25
|
+
|
|
26
|
+
# @!attribute [r] version
|
|
27
|
+
# @return [Factorix::MODVersion, nil] the version of the MOD, or nil if the version is not specified
|
|
28
|
+
|
|
29
|
+
# Check if the MOD is enabled
|
|
30
|
+
#
|
|
31
|
+
# @return [Boolean] true if the MOD is enabled, false otherwise
|
|
32
|
+
def enabled? = enabled
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
MODVersion = Data.define(:major, :minor, :patch)
|
|
5
|
+
|
|
6
|
+
# Represent a 3-component MOD version number (major.minor.patch)
|
|
7
|
+
#
|
|
8
|
+
# This class represents Factorio's MOD version format, which uses
|
|
9
|
+
# 24 bits (3 x 8-bit unsigned integers) to store version information.
|
|
10
|
+
#
|
|
11
|
+
# @see https://wiki.factorio.com/Version_string_format
|
|
12
|
+
class MODVersion
|
|
13
|
+
include Comparable
|
|
14
|
+
|
|
15
|
+
# @!attribute [r] major
|
|
16
|
+
# @return [Integer] major version number (0-255)
|
|
17
|
+
# @!attribute [r] minor
|
|
18
|
+
# @return [Integer] minor version number (0-255)
|
|
19
|
+
# @!attribute [r] patch
|
|
20
|
+
# @return [Integer] patch version number (0-255)
|
|
21
|
+
|
|
22
|
+
UINT8_MAX = (2**8) - 1
|
|
23
|
+
private_constant :UINT8_MAX
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
private def validate_component(value, name)
|
|
27
|
+
raise VersionParseError, "#{name} must be an Integer, got #{value.class}" unless value.is_a?(Integer)
|
|
28
|
+
return if value.between?(0, UINT8_MAX)
|
|
29
|
+
|
|
30
|
+
raise VersionParseError, "#{name} must be between 0 and #{UINT8_MAX}, got #{value}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Create MODVersion from version string "X.Y.Z" or "X.Y"
|
|
35
|
+
#
|
|
36
|
+
# Accepts both 3-part (X.Y.Z) and 2-part (X.Y) version strings.
|
|
37
|
+
# For 2-part versions, patch defaults to 0.
|
|
38
|
+
#
|
|
39
|
+
# @param str [String] version string in "X.Y.Z" or "X.Y" format
|
|
40
|
+
# @return [MODVersion]
|
|
41
|
+
# @raise [VersionParseError] if string format is invalid
|
|
42
|
+
def self.from_string(str)
|
|
43
|
+
if /\A(\d+)\.(\d+)\.(\d+)\z/ =~ str
|
|
44
|
+
major = Integer($1, 10)
|
|
45
|
+
minor = Integer($2, 10)
|
|
46
|
+
patch = Integer($3, 10)
|
|
47
|
+
elsif /\A(\d+)\.(\d+)\z/ =~ str
|
|
48
|
+
major = Integer($1, 10)
|
|
49
|
+
minor = Integer($2, 10)
|
|
50
|
+
patch = 0
|
|
51
|
+
else
|
|
52
|
+
raise VersionParseError, "invalid version string: #{str.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
validate_component(major, :major)
|
|
56
|
+
validate_component(minor, :minor)
|
|
57
|
+
validate_component(patch, :patch)
|
|
58
|
+
|
|
59
|
+
new(major:, minor:, patch:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Create MODVersion from three integers
|
|
63
|
+
#
|
|
64
|
+
# @param major [Integer] major version number (0-255)
|
|
65
|
+
# @param minor [Integer] minor version number (0-255)
|
|
66
|
+
# @param patch [Integer] patch version number (0-255)
|
|
67
|
+
# @return [MODVersion]
|
|
68
|
+
# @raise [VersionParseError] if any component is out of range
|
|
69
|
+
def self.from_numbers(major, minor, patch)
|
|
70
|
+
validate_component(major, :major)
|
|
71
|
+
validate_component(minor, :minor)
|
|
72
|
+
validate_component(patch, :patch)
|
|
73
|
+
|
|
74
|
+
new(major:, minor:, patch:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private_class_method :new, :[]
|
|
78
|
+
|
|
79
|
+
# Convert to string representation
|
|
80
|
+
#
|
|
81
|
+
# @return [String] Version string in format "X.Y.Z"
|
|
82
|
+
def to_s = "#{major}.#{minor}.#{patch}"
|
|
83
|
+
|
|
84
|
+
# Convert to array of integers
|
|
85
|
+
#
|
|
86
|
+
# @return [Array<Integer>] Array containing [major, minor, patch]
|
|
87
|
+
def to_a = [major, minor, patch].freeze
|
|
88
|
+
|
|
89
|
+
# Compare with another MODVersion
|
|
90
|
+
#
|
|
91
|
+
# @param other [MODVersion] Version to compare with
|
|
92
|
+
# @return [Integer, nil] -1, 0, 1 for less than, equal to, greater than; nil if not comparable
|
|
93
|
+
def <=>(other)
|
|
94
|
+
return nil unless other.is_a?(MODVersion)
|
|
95
|
+
|
|
96
|
+
to_a <=> other.to_a
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
# High-level API wrapper for Factorio MOD Portal
|
|
5
|
+
#
|
|
6
|
+
# Provides object-oriented interface by converting API responses (Hash)
|
|
7
|
+
# to typed value objects (MODInfo, Release, etc.).
|
|
8
|
+
#
|
|
9
|
+
# @example List all MODs
|
|
10
|
+
# portal = Factorix::Portal.new
|
|
11
|
+
# mods = portal.list_mods(page_size: 10)
|
|
12
|
+
# mods.each { |mod| puts "#{mod.name}: #{mod.title}" }
|
|
13
|
+
#
|
|
14
|
+
# @example Get MOD information
|
|
15
|
+
# mod = portal.get_mod("space-exploration")
|
|
16
|
+
# puts mod.summary
|
|
17
|
+
#
|
|
18
|
+
# @example Get full MOD details
|
|
19
|
+
# mod = portal.get_mod_full("space-exploration")
|
|
20
|
+
# puts mod.detail.description if mod.detail
|
|
21
|
+
#
|
|
22
|
+
# @example Download a MOD
|
|
23
|
+
# mod = portal.get_mod_full("space-exploration")
|
|
24
|
+
# release = mod.releases.max_by(&:released_at) # Get latest by release date
|
|
25
|
+
# portal.download_mod(release, Pathname("downloads/mod.zip")) if release
|
|
26
|
+
class Portal
|
|
27
|
+
# @!parse
|
|
28
|
+
# # @return [API::MODPortalAPI]
|
|
29
|
+
# attr_reader :mod_portal_api
|
|
30
|
+
# # @return [API::MODDownloadAPI]
|
|
31
|
+
# attr_reader :mod_download_api
|
|
32
|
+
# # @return [API::MODManagementAPI]
|
|
33
|
+
# attr_reader :mod_management_api
|
|
34
|
+
# # @return [Dry::Logger::Dispatcher]
|
|
35
|
+
# attr_reader :logger
|
|
36
|
+
include Import[:mod_portal_api, :mod_download_api, :mod_management_api, :logger]
|
|
37
|
+
|
|
38
|
+
# List MODs from the MOD Portal
|
|
39
|
+
#
|
|
40
|
+
# @param namelist [Array<String>] MOD names to filter (positional arguments)
|
|
41
|
+
# @param hide_deprecated [Boolean, nil] hide deprecated MODs
|
|
42
|
+
# @param page [Integer, nil] page number (1-based)
|
|
43
|
+
# @param page_size [Integer, nil] number of results per page
|
|
44
|
+
# @param sort [String, nil] sort field (name, created_at, updated_at)
|
|
45
|
+
# @param sort_order [String, nil] sort order (asc, desc)
|
|
46
|
+
# @param version [String, nil] Factorio version filter
|
|
47
|
+
# @return [Array<API::MODInfo>] array of MODInfo objects
|
|
48
|
+
def list_mods(...)
|
|
49
|
+
response = mod_portal_api.get_mods(...)
|
|
50
|
+
response[:results].map {|mod_data| API::MODInfo[**mod_data] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get basic information for a specific MOD (Short API)
|
|
54
|
+
#
|
|
55
|
+
# @param name [String] MOD name
|
|
56
|
+
# @return [API::MODInfo] MODInfo object (without Detail)
|
|
57
|
+
def get_mod(name)
|
|
58
|
+
data = mod_portal_api.get_mod(name)
|
|
59
|
+
API::MODInfo[**data]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get full information for a specific MOD (Full API)
|
|
63
|
+
#
|
|
64
|
+
# @param name [String] MOD name
|
|
65
|
+
# @return [API::MODInfo] MODInfo object (with Detail if available)
|
|
66
|
+
def get_mod_full(name)
|
|
67
|
+
data = mod_portal_api.get_mod_full(name)
|
|
68
|
+
API::MODInfo[**data]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Download a MOD release file
|
|
72
|
+
#
|
|
73
|
+
# @param release [API::Release] release object containing download_url and sha1
|
|
74
|
+
# @param output [Pathname] output file path
|
|
75
|
+
# @return [void]
|
|
76
|
+
# @raise [DigestMismatchError] if SHA1 verification fails
|
|
77
|
+
def download_mod(release, output)
|
|
78
|
+
# Extract path from URI::HTTPS
|
|
79
|
+
download_path = release.download_url.path
|
|
80
|
+
mod_download_api.download(download_path, output, expected_sha1: release.sha1)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Upload a MOD file to the portal
|
|
84
|
+
#
|
|
85
|
+
# Automatically detects if this is a new MOD or update:
|
|
86
|
+
# - For new MODs: uses init_publish and includes metadata in finish_upload
|
|
87
|
+
# - For existing MODs: uses init_upload, then updates metadata via edit_details
|
|
88
|
+
#
|
|
89
|
+
# @param mod_name [String] the MOD name
|
|
90
|
+
# @param file_path [Pathname] path to MOD zip file
|
|
91
|
+
# @param metadata [Hash] optional metadata
|
|
92
|
+
# @option metadata [String] :description Markdown description
|
|
93
|
+
# @option metadata [String] :category MOD category
|
|
94
|
+
# @option metadata [String] :license License identifier
|
|
95
|
+
# @option metadata [String] :source_url Repository URL
|
|
96
|
+
# @return [void]
|
|
97
|
+
# @raise [HTTPClientError] for 4xx errors
|
|
98
|
+
# @raise [HTTPServerError] for 5xx errors
|
|
99
|
+
def upload_mod(mod_name, file_path, **metadata)
|
|
100
|
+
mod_exists = begin
|
|
101
|
+
get_mod(mod_name)
|
|
102
|
+
logger.info("Uploading new release to existing MOD", mod: mod_name)
|
|
103
|
+
true
|
|
104
|
+
rescue MODNotOnPortalError
|
|
105
|
+
logger.info("Publishing new MOD", mod: mod_name)
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
upload_url = mod_exists ? mod_management_api.init_upload(mod_name) : mod_management_api.init_publish(mod_name)
|
|
110
|
+
|
|
111
|
+
if mod_exists
|
|
112
|
+
# For existing MODs: upload file, then edit metadata separately
|
|
113
|
+
mod_management_api.finish_upload(mod_name, upload_url, file_path)
|
|
114
|
+
mod_management_api.edit_details(mod_name, **metadata) unless metadata.empty?
|
|
115
|
+
else
|
|
116
|
+
# For new MODs: upload file with metadata
|
|
117
|
+
mod_management_api.finish_upload(mod_name, upload_url, file_path, **metadata)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
logger.info("Upload completed successfully", mod: mod_name)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Edit MOD metadata without uploading new file
|
|
124
|
+
#
|
|
125
|
+
# @param mod_name [String] the MOD name
|
|
126
|
+
# @param metadata [Hash] metadata to update
|
|
127
|
+
# @option metadata [String] :description Markdown description
|
|
128
|
+
# @option metadata [String] :summary Brief description
|
|
129
|
+
# @option metadata [String] :title MOD title
|
|
130
|
+
# @option metadata [String] :category MOD category
|
|
131
|
+
# @option metadata [Array<String>] :tags Array of tags
|
|
132
|
+
# @option metadata [String] :license License identifier
|
|
133
|
+
# @option metadata [String] :homepage Homepage URL
|
|
134
|
+
# @option metadata [String] :source_url Repository URL
|
|
135
|
+
# @option metadata [String] :faq FAQ text
|
|
136
|
+
# @option metadata [Boolean] :deprecated Deprecation flag
|
|
137
|
+
# @return [void]
|
|
138
|
+
# @raise [MODSettingsError] if no metadata provided
|
|
139
|
+
# @raise [HTTPClientError] for 4xx errors
|
|
140
|
+
# @raise [HTTPServerError] for 5xx errors
|
|
141
|
+
def edit_mod(mod_name, **metadata)
|
|
142
|
+
raise MODSettingsError, "No metadata provided" if metadata.empty?
|
|
143
|
+
|
|
144
|
+
logger.info("Editing MOD metadata", mod: mod_name, fields: metadata.keys)
|
|
145
|
+
mod_management_api.edit_details(mod_name, **metadata)
|
|
146
|
+
logger.info("Metadata updated successfully", mod: mod_name)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Add an image to a MOD
|
|
150
|
+
#
|
|
151
|
+
# @param mod_name [String] the MOD name
|
|
152
|
+
# @param image_file [Pathname] path to image file
|
|
153
|
+
# @return [API::Image] the uploaded image info
|
|
154
|
+
# @raise [HTTPClientError] for 4xx errors
|
|
155
|
+
# @raise [HTTPServerError] for 5xx errors
|
|
156
|
+
def add_mod_image(mod_name, image_file)
|
|
157
|
+
logger.info("Adding image to MOD", mod: mod_name, file: image_file.to_s)
|
|
158
|
+
|
|
159
|
+
# Initialize upload
|
|
160
|
+
upload_url = mod_management_api.init_image_upload(mod_name)
|
|
161
|
+
|
|
162
|
+
# Upload image
|
|
163
|
+
response_data = mod_management_api.finish_image_upload(mod_name, upload_url, image_file)
|
|
164
|
+
|
|
165
|
+
# Convert response to API::Image
|
|
166
|
+
image = API::Image[**response_data.transform_keys(&:to_sym)]
|
|
167
|
+
|
|
168
|
+
logger.info("Image added successfully", mod: mod_name, image_id: image.id)
|
|
169
|
+
image
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Edit MOD's image list
|
|
173
|
+
#
|
|
174
|
+
# @param mod_name [String] the MOD name
|
|
175
|
+
# @param image_ids [Array<String>] array of image IDs in desired order
|
|
176
|
+
# @return [void]
|
|
177
|
+
# @raise [HTTPClientError] for 4xx errors
|
|
178
|
+
# @raise [HTTPServerError] for 5xx errors
|
|
179
|
+
def edit_mod_images(mod_name, image_ids)
|
|
180
|
+
logger.info("Editing MOD images", mod: mod_name, image_count: image_ids.size)
|
|
181
|
+
mod_management_api.edit_images(mod_name, image_ids)
|
|
182
|
+
logger.info("Images updated successfully", mod: mod_name)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Factorix
|
|
4
|
+
module Progress
|
|
5
|
+
# Download event handler for progress presenters
|
|
6
|
+
#
|
|
7
|
+
# This class listens to download events and updates a progress presenter accordingly.
|
|
8
|
+
class DownloadHandler
|
|
9
|
+
# Create a new download handler
|
|
10
|
+
#
|
|
11
|
+
# @param presenter [Presenter, PresenterAdapter] progress presenter to update
|
|
12
|
+
def initialize(presenter) = @presenter = presenter
|
|
13
|
+
|
|
14
|
+
# Handle download started event
|
|
15
|
+
#
|
|
16
|
+
# @param event [Dry::Events::Event] event with total_size payload
|
|
17
|
+
# @return [void]
|
|
18
|
+
def on_download_started(event) = @presenter.start(total: event[:total_size])
|
|
19
|
+
|
|
20
|
+
# Handle download progress event
|
|
21
|
+
#
|
|
22
|
+
# @param event [Dry::Events::Event] event with current_size payload
|
|
23
|
+
# @return [void]
|
|
24
|
+
def on_download_progress(event) = @presenter.update(event[:current_size])
|
|
25
|
+
|
|
26
|
+
# Handle download completed event
|
|
27
|
+
#
|
|
28
|
+
# @param event [Dry::Events::Event] event with total_size payload
|
|
29
|
+
# @return [void]
|
|
30
|
+
def on_download_completed(_event) = @presenter.finish
|
|
31
|
+
|
|
32
|
+
# Handle cache hit event
|
|
33
|
+
#
|
|
34
|
+
# @param event [Dry::Events::Event] event with url, output, and total_size payload
|
|
35
|
+
# @return [void]
|
|
36
|
+
def on_cache_hit(event)
|
|
37
|
+
total_size = event.payload.fetch(:total_size, 1)
|
|
38
|
+
|
|
39
|
+
# Start and complete immediately for cache hits
|
|
40
|
+
@presenter.start(total: total_size)
|
|
41
|
+
@presenter.update(total_size)
|
|
42
|
+
@presenter.finish
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-progressbar"
|
|
4
|
+
|
|
5
|
+
module Factorix
|
|
6
|
+
module Progress
|
|
7
|
+
# Multi-progress presenter implementation
|
|
8
|
+
#
|
|
9
|
+
# This class provides a multi-progress presentation interface using tty-progressbar.
|
|
10
|
+
# It manages multiple progress bars that can be updated concurrently.
|
|
11
|
+
class MultiPresenter
|
|
12
|
+
# Create a new multi-progress presenter
|
|
13
|
+
#
|
|
14
|
+
# @param title [String] title of the multi-progress presenter
|
|
15
|
+
# @param output [IO] output stream for the progress presenter
|
|
16
|
+
def initialize(title: "Progress", output: $stderr)
|
|
17
|
+
@title = title
|
|
18
|
+
@output = output
|
|
19
|
+
@multi = TTY::ProgressBar::Multi.new(@title, output: @output, style: {top: "", middle: "", bottom: ""})
|
|
20
|
+
@presenters = {}
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Register a new progress presenter
|
|
25
|
+
#
|
|
26
|
+
# @param name [String, Symbol] unique identifier for this progress presenter
|
|
27
|
+
# @param title [String] title for this specific progress presenter
|
|
28
|
+
# @return [PresenterAdapter] adapter wrapping the TTY::ProgressBar
|
|
29
|
+
def register(name, title:)
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
tty_bar = @multi.register("#{title} [:bar] :percent :byte/:total_byte")
|
|
32
|
+
adapter = PresenterAdapter.new(tty_bar, @mutex)
|
|
33
|
+
@presenters[name] = adapter
|
|
34
|
+
adapter
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get a registered presenter by name
|
|
39
|
+
#
|
|
40
|
+
# @param name [String, Symbol] the identifier used during registration
|
|
41
|
+
# @return [PresenterAdapter, nil] the presenter adapter or nil if not found
|
|
42
|
+
def [](name) = @presenters[name]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|