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.
Files changed (202) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/completion/_factorix.bash +202 -0
  6. data/completion/_factorix.fish +197 -0
  7. data/completion/_factorix.zsh +376 -0
  8. data/doc/factorix.1 +377 -0
  9. data/exe/factorix +20 -0
  10. data/lib/factorix/api/category.rb +69 -0
  11. data/lib/factorix/api/image.rb +35 -0
  12. data/lib/factorix/api/license.rb +71 -0
  13. data/lib/factorix/api/mod_download_api.rb +66 -0
  14. data/lib/factorix/api/mod_info.rb +166 -0
  15. data/lib/factorix/api/mod_management_api.rb +237 -0
  16. data/lib/factorix/api/mod_portal_api.rb +204 -0
  17. data/lib/factorix/api/release.rb +49 -0
  18. data/lib/factorix/api/tag.rb +95 -0
  19. data/lib/factorix/api.rb +7 -0
  20. data/lib/factorix/api_credential.rb +54 -0
  21. data/lib/factorix/application.rb +218 -0
  22. data/lib/factorix/cache/file_system.rb +307 -0
  23. data/lib/factorix/cli/commands/backup_support.rb +46 -0
  24. data/lib/factorix/cli/commands/base.rb +90 -0
  25. data/lib/factorix/cli/commands/cache/evict.rb +180 -0
  26. data/lib/factorix/cli/commands/cache/stat.rb +201 -0
  27. data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
  28. data/lib/factorix/cli/commands/completion.rb +83 -0
  29. data/lib/factorix/cli/commands/confirmable.rb +53 -0
  30. data/lib/factorix/cli/commands/download_support.rb +123 -0
  31. data/lib/factorix/cli/commands/launch.rb +79 -0
  32. data/lib/factorix/cli/commands/man.rb +29 -0
  33. data/lib/factorix/cli/commands/mod/check.rb +99 -0
  34. data/lib/factorix/cli/commands/mod/disable.rb +188 -0
  35. data/lib/factorix/cli/commands/mod/download.rb +291 -0
  36. data/lib/factorix/cli/commands/mod/edit.rb +114 -0
  37. data/lib/factorix/cli/commands/mod/enable.rb +216 -0
  38. data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
  39. data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
  40. data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
  41. data/lib/factorix/cli/commands/mod/install.rb +443 -0
  42. data/lib/factorix/cli/commands/mod/list.rb +372 -0
  43. data/lib/factorix/cli/commands/mod/search.rb +134 -0
  44. data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
  45. data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
  46. data/lib/factorix/cli/commands/mod/show.rb +202 -0
  47. data/lib/factorix/cli/commands/mod/sync.rb +299 -0
  48. data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
  49. data/lib/factorix/cli/commands/mod/update.rb +222 -0
  50. data/lib/factorix/cli/commands/mod/upload.rb +90 -0
  51. data/lib/factorix/cli/commands/path.rb +79 -0
  52. data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
  53. data/lib/factorix/cli/commands/version.rb +25 -0
  54. data/lib/factorix/cli.rb +42 -0
  55. data/lib/factorix/dependency/edge.rb +89 -0
  56. data/lib/factorix/dependency/entry.rb +124 -0
  57. data/lib/factorix/dependency/graph/builder.rb +108 -0
  58. data/lib/factorix/dependency/graph.rb +210 -0
  59. data/lib/factorix/dependency/list.rb +244 -0
  60. data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
  61. data/lib/factorix/dependency/node.rb +60 -0
  62. data/lib/factorix/dependency/parser.rb +148 -0
  63. data/lib/factorix/dependency/validation_result.rb +138 -0
  64. data/lib/factorix/dependency/validator.rb +190 -0
  65. data/lib/factorix/errors.rb +112 -0
  66. data/lib/factorix/formatting.rb +56 -0
  67. data/lib/factorix/game_version.rb +98 -0
  68. data/lib/factorix/http/cache_decorator.rb +106 -0
  69. data/lib/factorix/http/cached_response.rb +37 -0
  70. data/lib/factorix/http/client.rb +187 -0
  71. data/lib/factorix/http/response.rb +31 -0
  72. data/lib/factorix/http/retry_decorator.rb +59 -0
  73. data/lib/factorix/http/retry_strategy.rb +80 -0
  74. data/lib/factorix/info_json.rb +90 -0
  75. data/lib/factorix/installed_mod.rb +239 -0
  76. data/lib/factorix/mod.rb +55 -0
  77. data/lib/factorix/mod_list.rb +174 -0
  78. data/lib/factorix/mod_settings.rb +278 -0
  79. data/lib/factorix/mod_state.rb +34 -0
  80. data/lib/factorix/mod_version.rb +99 -0
  81. data/lib/factorix/portal.rb +185 -0
  82. data/lib/factorix/progress/download_handler.rb +46 -0
  83. data/lib/factorix/progress/multi_presenter.rb +45 -0
  84. data/lib/factorix/progress/presenter.rb +67 -0
  85. data/lib/factorix/progress/presenter_adapter.rb +46 -0
  86. data/lib/factorix/progress/scan_handler.rb +33 -0
  87. data/lib/factorix/progress/upload_handler.rb +33 -0
  88. data/lib/factorix/runtime/base.rb +233 -0
  89. data/lib/factorix/runtime/linux.rb +32 -0
  90. data/lib/factorix/runtime/mac_os.rb +53 -0
  91. data/lib/factorix/runtime/user_configurable.rb +69 -0
  92. data/lib/factorix/runtime/windows.rb +85 -0
  93. data/lib/factorix/runtime/wsl.rb +118 -0
  94. data/lib/factorix/runtime.rb +32 -0
  95. data/lib/factorix/save_file.rb +178 -0
  96. data/lib/factorix/ser_des/deserializer.rb +198 -0
  97. data/lib/factorix/ser_des/serializer.rb +231 -0
  98. data/lib/factorix/ser_des/signed_integer.rb +63 -0
  99. data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
  100. data/lib/factorix/service_credential.rb +127 -0
  101. data/lib/factorix/transfer/downloader.rb +162 -0
  102. data/lib/factorix/transfer/uploader.rb +232 -0
  103. data/lib/factorix/version.rb +6 -0
  104. data/lib/factorix.rb +38 -0
  105. data/sig/dry/auto_inject.rbs +15 -0
  106. data/sig/dry/cli.rbs +19 -0
  107. data/sig/dry/configurable.rbs +13 -0
  108. data/sig/dry/core/container.rbs +17 -0
  109. data/sig/dry/events/publisher.rbs +22 -0
  110. data/sig/dry/logger.rbs +16 -0
  111. data/sig/factorix/api/category.rbs +15 -0
  112. data/sig/factorix/api/image.rbs +15 -0
  113. data/sig/factorix/api/license.rbs +20 -0
  114. data/sig/factorix/api/mod_download_api.rbs +18 -0
  115. data/sig/factorix/api/mod_info.rbs +67 -0
  116. data/sig/factorix/api/mod_management_api.rbs +25 -0
  117. data/sig/factorix/api/mod_portal_api.rbs +31 -0
  118. data/sig/factorix/api/release.rbs +27 -0
  119. data/sig/factorix/api/tag.rbs +15 -0
  120. data/sig/factorix/api.rbs +8 -0
  121. data/sig/factorix/api_credential.rbs +17 -0
  122. data/sig/factorix/application.rbs +86 -0
  123. data/sig/factorix/cache/file_system.rbs +35 -0
  124. data/sig/factorix/cli/commands/base.rbs +13 -0
  125. data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
  126. data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
  127. data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
  128. data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
  129. data/sig/factorix/cli/commands/confirmable.rbs +12 -0
  130. data/sig/factorix/cli/commands/download_support.rbs +12 -0
  131. data/sig/factorix/cli/commands/launch.rbs +15 -0
  132. data/sig/factorix/cli/commands/mod/check.rbs +18 -0
  133. data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
  134. data/sig/factorix/cli/commands/mod/download.rbs +18 -0
  135. data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
  136. data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
  137. data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
  138. data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
  139. data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
  140. data/sig/factorix/cli/commands/mod/install.rbs +19 -0
  141. data/sig/factorix/cli/commands/mod/list.rbs +30 -0
  142. data/sig/factorix/cli/commands/mod/search.rbs +18 -0
  143. data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
  144. data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
  145. data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
  146. data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
  147. data/sig/factorix/cli/commands/mod/update.rbs +19 -0
  148. data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
  149. data/sig/factorix/cli/commands/path.rbs +18 -0
  150. data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
  151. data/sig/factorix/cli/commands/version.rbs +13 -0
  152. data/sig/factorix/cli.rbs +11 -0
  153. data/sig/factorix/dependency/edge.rbs +32 -0
  154. data/sig/factorix/dependency/entry.rbs +30 -0
  155. data/sig/factorix/dependency/graph/builder.rbs +17 -0
  156. data/sig/factorix/dependency/graph.rbs +39 -0
  157. data/sig/factorix/dependency/list.rbs +69 -0
  158. data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
  159. data/sig/factorix/dependency/node.rbs +24 -0
  160. data/sig/factorix/dependency/parser.rbs +11 -0
  161. data/sig/factorix/dependency/validation_result.rbs +56 -0
  162. data/sig/factorix/dependency/validator.rbs +13 -0
  163. data/sig/factorix/errors.rbs +132 -0
  164. data/sig/factorix/formatting.rbs +8 -0
  165. data/sig/factorix/game_version.rbs +24 -0
  166. data/sig/factorix/http/cache_decorator.rbs +64 -0
  167. data/sig/factorix/http/client.rbs +55 -0
  168. data/sig/factorix/http/response.rbs +28 -0
  169. data/sig/factorix/http/retry_decorator.rbs +44 -0
  170. data/sig/factorix/http/retry_strategy.rbs +42 -0
  171. data/sig/factorix/info_json.rbs +19 -0
  172. data/sig/factorix/installed_mod.rbs +34 -0
  173. data/sig/factorix/mod.rbs +20 -0
  174. data/sig/factorix/mod_list.rbs +44 -0
  175. data/sig/factorix/mod_settings.rbs +47 -0
  176. data/sig/factorix/mod_state.rbs +18 -0
  177. data/sig/factorix/mod_version.rbs +23 -0
  178. data/sig/factorix/portal.rbs +37 -0
  179. data/sig/factorix/progress/download_handler.rbs +19 -0
  180. data/sig/factorix/progress/multi_presenter.rbs +15 -0
  181. data/sig/factorix/progress/presenter.rbs +17 -0
  182. data/sig/factorix/progress/presenter_adapter.rbs +17 -0
  183. data/sig/factorix/progress/scan_handler.rbs +16 -0
  184. data/sig/factorix/progress/upload_handler.rbs +17 -0
  185. data/sig/factorix/runtime/base.rbs +45 -0
  186. data/sig/factorix/runtime/linux.rbs +15 -0
  187. data/sig/factorix/runtime/mac_os.rbs +15 -0
  188. data/sig/factorix/runtime/user_configurable.rbs +13 -0
  189. data/sig/factorix/runtime/windows.rbs +23 -0
  190. data/sig/factorix/runtime/wsl.rbs +19 -0
  191. data/sig/factorix/runtime.rbs +9 -0
  192. data/sig/factorix/save_file.rbs +40 -0
  193. data/sig/factorix/ser_des/deserializer.rbs +49 -0
  194. data/sig/factorix/ser_des/serializer.rbs +45 -0
  195. data/sig/factorix/ser_des/signed_integer.rbs +37 -0
  196. data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
  197. data/sig/factorix/service_credential.rbs +19 -0
  198. data/sig/factorix/transfer/downloader.rbs +15 -0
  199. data/sig/factorix/transfer/uploader.rbs +21 -0
  200. data/sig/factorix.rbs +9 -0
  201. data/sig/tty/progressbar.rbs +18 -0
  202. 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