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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "uri"
5
+
6
+ module Factorix
7
+ module API
8
+ MODInfo = Data.define(:name, :title, :owner, :summary, :downloads_count, :category, :score, :thumbnail, :latest_release, :releases, :detail)
9
+
10
+ # MOD information from MOD Portal API
11
+ #
12
+ # Represents MOD metadata from various API endpoints:
13
+ # - /api/mods (list)
14
+ # - /api/mods/{name} (Short)
15
+ # - /api/mods/{name}/full (Full)
16
+ #
17
+ # @see https://wiki.factorio.com/Mod_portal_API
18
+ class MODInfo
19
+ # @!attribute [r] name
20
+ # @return [String] internal MOD name (unique identifier)
21
+ # @!attribute [r] title
22
+ # @return [String] human-readable MOD title
23
+ # @!attribute [r] owner
24
+ # @return [String] MOD owner username
25
+ # @!attribute [r] summary
26
+ # @return [String] short description
27
+ # @!attribute [r] downloads_count
28
+ # @return [Integer] total number of downloads
29
+ # @!attribute [r] category
30
+ # @return [Category] MOD category
31
+ # @!attribute [r] score
32
+ # @return [Float] MOD score/rating
33
+ # @!attribute [r] thumbnail
34
+ # @return [URI::HTTPS, nil] thumbnail image URL
35
+ # @!attribute [r] latest_release
36
+ # @return [Release, nil] latest release (list API without namelist)
37
+ # @!attribute [r] releases
38
+ # @return [Array<Release>] all releases
39
+ # @!attribute [r] detail
40
+ # @return [Detail, nil] detailed information (Full API only)
41
+
42
+ Detail = Data.define(:changelog, :created_at, :updated_at, :last_highlighted_at, :description, :source_url, :homepage, :faq, :tags, :license, :images, :deprecated)
43
+ DETAIL_ALLOWED_KEYS = %i[changelog created_at updated_at last_highlighted_at description source_url homepage faq tags license images deprecated].freeze
44
+ private_constant :DETAIL_ALLOWED_KEYS
45
+
46
+ # Detailed MOD information from Full API endpoint
47
+ #
48
+ # @see https://wiki.factorio.com/Mod_portal_API
49
+ class Detail
50
+ # @!attribute [r] changelog
51
+ # @return [String] changelog text
52
+ # @!attribute [r] created_at
53
+ # @return [Time] creation timestamp
54
+ # @!attribute [r] updated_at
55
+ # @return [Time] last update timestamp
56
+ # @!attribute [r] last_highlighted_at
57
+ # @return [Time, nil] last highlighted timestamp
58
+ # @!attribute [r] description
59
+ # @return [String] detailed description text
60
+ # @!attribute [r] source_url
61
+ # @return [URI::HTTPS, nil] source repository URL
62
+ # @!attribute [r] homepage
63
+ # @return [URI, String] homepage URL or string
64
+ # @!attribute [r] faq
65
+ # @return [String] FAQ text
66
+ # @!attribute [r] tags
67
+ # @return [Array<Tag>] tags
68
+ # @!attribute [r] license
69
+ # @return [License, nil] license information
70
+ # @!attribute [r] images
71
+ # @return [Array<Image>] images
72
+ # @!attribute [r] deprecated
73
+ # @return [Boolean] deprecation status
74
+
75
+ # Create Detail from API response hash
76
+ #
77
+ # @param changelog [String, nil] changelog
78
+ # @param created_at [String] ISO 8601 timestamp
79
+ # @param updated_at [String] ISO 8601 timestamp
80
+ # @param last_highlighted_at [String, nil] ISO 8601 timestamp
81
+ # @param description [String, nil] description
82
+ # @param source_url [String, nil] source URL
83
+ # @param homepage [String] homepage URL or string
84
+ # @param faq [String, nil] FAQ
85
+ # @param tags [Array<String>, nil] tags
86
+ # @param license [Hash, nil] license data
87
+ # @param images [Array<Hash>, nil] images data
88
+ # @param deprecated [Boolean, nil] deprecated flag
89
+ # @return [Detail] new Detail instance
90
+ def initialize(created_at:, updated_at:, homepage:, changelog: nil, last_highlighted_at: nil, description: nil, source_url: nil, faq: nil, tags: nil, license: nil, images: nil, deprecated: nil)
91
+ changelog ||= ""
92
+ created_at = Time.parse(created_at).utc
93
+ updated_at = Time.parse(updated_at).utc
94
+ last_highlighted_at = last_highlighted_at ? Time.parse(last_highlighted_at).utc : nil
95
+ description ||= ""
96
+ source_url = parse_uri(source_url)
97
+ homepage = parse_uri(homepage)
98
+ faq ||= ""
99
+ tags = (tags || []).map {|tag_value| Tag.for(tag_value) }
100
+ license = license ? License[**license] : nil
101
+ images = (images || []).map {|img| Image[**img] }
102
+ deprecated ||= false
103
+
104
+ super
105
+ end
106
+
107
+ # Check if the MOD is deprecated
108
+ #
109
+ # @return [Boolean] true if deprecated
110
+ def deprecated? = deprecated
111
+
112
+ private def parse_uri(value)
113
+ return nil if value.nil? || value.empty?
114
+
115
+ URI(value)
116
+ rescue URI::InvalidURIError => e
117
+ Application[:logger].warn("Skipping invalid URI '#{value}': #{e.message}")
118
+ nil
119
+ end
120
+ end
121
+
122
+ # Create MODInfo from API response hash
123
+ #
124
+ # @param name [String] MOD name
125
+ # @param title [String] MOD title
126
+ # @param owner [String] owner username
127
+ # @param summary [String, nil] summary
128
+ # @param downloads_count [Integer] download count
129
+ # @param category [String, nil] category value
130
+ # @param score [Float, nil] score
131
+ # @param thumbnail [String, nil] thumbnail path
132
+ # @param latest_release [Hash, nil] latest release data
133
+ # @param releases [Array<Hash>, nil] releases data
134
+ # @param detail [Hash, nil] detail data
135
+ # @return [MODInfo] new MODInfo instance
136
+ def initialize(name:, title:, owner:, downloads_count:, summary: nil, category: nil, score: nil, thumbnail: nil, latest_release: nil, releases: nil, **detail_fields)
137
+ summary ||= ""
138
+ category = Category.for(category || "")
139
+ score ||= 0.0
140
+ thumbnail = thumbnail ? build_thumbnail_uri(thumbnail) : nil
141
+ latest_release = latest_release ? Release[**latest_release] : nil
142
+ releases = (releases || []).filter_map {|r|
143
+ # NOTE: begin is required because {...} blocks cannot use rescue directly
144
+ begin
145
+ Release[**r]
146
+ rescue RangeError => e
147
+ # Skip releases with invalid version numbers
148
+ Application[:logger].warn("Skipping release #{name}@#{r[:version]}: #{e.message}")
149
+ nil
150
+ end
151
+ }
152
+
153
+ # Filter detail_fields to only include keys that Detail.new accepts
154
+ # Exclude deprecated fields like github_path
155
+ detail = Detail.new(**detail_fields.slice(*DETAIL_ALLOWED_KEYS)) if all_required_detail_fields?(detail_fields)
156
+
157
+ super(name:, title:, owner:, summary:, downloads_count:, category:, score:, thumbnail:, latest_release:, releases:, detail:)
158
+ end
159
+
160
+ private def build_thumbnail_uri(path) = URI("https://assets-mod.factorio.com#{path}")
161
+
162
+ # Check if detail_fields contains all required fields for Detail
163
+ private def all_required_detail_fields?(detail_fields) = %i[created_at updated_at homepage].all? {|field| detail_fields.key?(field) }
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/events"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Factorix
8
+ module API
9
+ # API client for MOD management operations (upload, publish, edit)
10
+ #
11
+ # Requires API key authentication via APICredential.
12
+ # Uses api_credential lazy loading to avoid early environment variable evaluation.
13
+ class MODManagementAPI
14
+ # NOTE: api_credential is NOT imported to avoid early evaluation errors
15
+ # when FACTORIO_API_KEY environment variable is not set.
16
+ # It's resolved lazily via reader method instead.
17
+ # @!parse
18
+ # # @return [HTTP::Client]
19
+ # attr_reader :client
20
+ # # @return [Transfer::Uploader]
21
+ # attr_reader :uploader
22
+ # # @return [Dry::Logger::Dispatcher]
23
+ # attr_reader :logger
24
+ include Import[:uploader, :logger, client: :http_client]
25
+ include Dry::Events::Publisher[:mod_management]
26
+
27
+ register_event("mod.changed")
28
+
29
+ BASE_URL = "https://mods.factorio.com"
30
+ private_constant :BASE_URL
31
+
32
+ # Metadata fields allowed in finish_upload (only for init_publish scenario)
33
+ ALLOWED_UPLOAD_METADATA = %w[description category license source_url].freeze
34
+ private_constant :ALLOWED_UPLOAD_METADATA
35
+
36
+ # Metadata fields allowed in edit_details
37
+ ALLOWED_EDIT_METADATA = %w[description summary title category tags license homepage source_url faq deprecated].freeze
38
+ private_constant :ALLOWED_EDIT_METADATA
39
+
40
+ # Initialize with thread-safe credential loading
41
+ #
42
+ # @param args [Hash] dependency injection arguments
43
+ def initialize(...)
44
+ super
45
+ @api_credential_mutex = Mutex.new
46
+ end
47
+
48
+ # Initialize new MOD publication
49
+ #
50
+ # @param mod_name [String] the MOD name
51
+ # @return [URI::HTTPS] upload URL
52
+ # @raise [HTTPClientError] for 4xx errors (e.g., MOD already exists)
53
+ # @raise [HTTPServerError] for 5xx errors
54
+ def init_publish(mod_name)
55
+ uri = URI.join(BASE_URL, "/api/v2/mods/init_publish")
56
+ body = URI.encode_www_form({mod: mod_name})
57
+
58
+ logger.info("Initializing MOD publication", mod: mod_name)
59
+ response = client.post(uri, body:, headers: build_auth_header, content_type: "application/x-www-form-urlencoded")
60
+
61
+ parse_upload_url(response)
62
+ end
63
+
64
+ # Initialize update to existing MOD
65
+ #
66
+ # @param mod_name [String] the MOD name
67
+ # @return [URI::HTTPS] upload URL
68
+ # @raise [MODNotOnPortalError] if MOD not found on portal
69
+ # @raise [HTTPClientError] for other 4xx errors
70
+ # @raise [HTTPServerError] for 5xx errors
71
+ def init_upload(mod_name)
72
+ uri = URI.join(BASE_URL, "/api/v2/mods/releases/init_upload")
73
+ body = URI.encode_www_form({mod: mod_name})
74
+
75
+ logger.info("Initializing MOD upload", mod: mod_name)
76
+ response = client.post(uri, body:, headers: build_auth_header, content_type: "application/x-www-form-urlencoded")
77
+
78
+ parse_upload_url(response)
79
+ rescue HTTPNotFoundError => e
80
+ raise MODNotOnPortalError, e.api_message || "MOD '#{mod_name}' not found on portal"
81
+ end
82
+
83
+ # Complete upload (works for both publish and update scenarios)
84
+ #
85
+ # @param mod_name [String] the MOD name
86
+ # @param upload_url [URI::HTTPS] the upload URL from init_publish or init_upload
87
+ # @param file_path [Pathname] path to MOD zip file
88
+ # @param metadata [Hash] optional metadata (only used for init_publish)
89
+ # @option metadata [String] :description Markdown description
90
+ # @option metadata [String] :category MOD category
91
+ # @option metadata [String] :license License identifier
92
+ # @option metadata [String] :source_url Repository URL
93
+ # @return [void]
94
+ # @raise [HTTPClientError] for 4xx errors
95
+ # @raise [HTTPServerError] for 5xx errors
96
+ def finish_upload(mod_name, upload_url, file_path, **metadata)
97
+ validate_metadata!(metadata, ALLOWED_UPLOAD_METADATA, "finish_upload")
98
+
99
+ logger.info("Uploading MOD file", mod: mod_name, file: file_path.to_s, metadata_count: metadata.size)
100
+
101
+ fields = metadata.transform_keys(&:to_s)
102
+
103
+ uploader.upload(upload_url, file_path, fields:)
104
+ logger.info("Upload completed successfully", mod: mod_name)
105
+ publish("mod.changed", mod: mod_name)
106
+ end
107
+
108
+ # Edit MOD details (for post-upload metadata changes)
109
+ #
110
+ # @param mod_name [String] the MOD name
111
+ # @param metadata [Hash] metadata to update
112
+ # @option metadata [String] :description Markdown description
113
+ # @option metadata [String] :summary Brief description
114
+ # @option metadata [String] :title MOD title
115
+ # @option metadata [String] :category MOD category
116
+ # @option metadata [Array<String>] :tags Array of tags
117
+ # @option metadata [String] :license License identifier
118
+ # @option metadata [String] :homepage Homepage URL
119
+ # @option metadata [String] :source_url Repository URL
120
+ # @option metadata [String] :faq FAQ text
121
+ # @option metadata [Boolean] :deprecated Deprecation flag
122
+ # @return [void]
123
+ # @raise [MODNotOnPortalError] if MOD not found on portal
124
+ # @raise [HTTPClientError] for other 4xx errors
125
+ # @raise [HTTPServerError] for 5xx errors
126
+ def edit_details(mod_name, **metadata)
127
+ validate_metadata!(metadata, ALLOWED_EDIT_METADATA, "edit_details")
128
+
129
+ uri = URI.join(BASE_URL, "/api/v2/mods/edit_details")
130
+
131
+ # URI.encode_www_form handles arrays as multiple params (tags=a&tags=b)
132
+ form_data = {mod: mod_name, **metadata}.transform_keys(&:to_s)
133
+ body = URI.encode_www_form(form_data)
134
+
135
+ logger.info("Editing MOD details", mod: mod_name, fields: metadata.keys)
136
+ client.post(uri, body:, headers: build_auth_header, content_type: "application/x-www-form-urlencoded")
137
+ logger.info("Edit completed successfully", mod: mod_name)
138
+ publish("mod.changed", mod: mod_name)
139
+ rescue HTTPNotFoundError => e
140
+ raise MODNotOnPortalError, e.api_message || "MOD '#{mod_name}' not found on portal"
141
+ end
142
+
143
+ # Initialize image upload for a MOD
144
+ #
145
+ # @param mod_name [String] the MOD name
146
+ # @return [URI::HTTPS] upload URL
147
+ # @raise [MODNotOnPortalError] if MOD not found on portal
148
+ # @raise [HTTPClientError] for other 4xx errors
149
+ # @raise [HTTPServerError] for 5xx errors
150
+ def init_image_upload(mod_name)
151
+ uri = URI.join(BASE_URL, "/api/v2/mods/images/add")
152
+ body = URI.encode_www_form({mod: mod_name})
153
+
154
+ logger.info("Initializing image upload", mod: mod_name)
155
+ response = client.post(uri, body:, headers: build_auth_header, content_type: "application/x-www-form-urlencoded")
156
+
157
+ parse_upload_url(response)
158
+ rescue HTTPNotFoundError => e
159
+ raise MODNotOnPortalError, e.api_message || "MOD '#{mod_name}' not found on portal"
160
+ end
161
+
162
+ # Complete image upload
163
+ #
164
+ # @param mod_name [String] the MOD name
165
+ # @param upload_url [URI::HTTPS] the upload URL from init_image_upload
166
+ # @param image_file [Pathname] path to image file
167
+ # @return [Hash] parsed response with image info (id, url, thumbnail)
168
+ # @raise [HTTPClientError] for 4xx errors
169
+ # @raise [HTTPServerError] for 5xx errors
170
+ def finish_image_upload(mod_name, upload_url, image_file)
171
+ logger.info("Uploading image file", mod: mod_name, file: image_file.to_s)
172
+
173
+ response = uploader.upload(upload_url, image_file, field_name: "image")
174
+ data = JSON.parse(response.body)
175
+
176
+ logger.info("Image upload completed successfully", mod: mod_name, image_id: data["id"])
177
+ publish("mod.changed", mod: mod_name)
178
+ data
179
+ rescue JSON::ParserError => e
180
+ raise HTTPError, "Invalid JSON response: #{e.message}"
181
+ end
182
+
183
+ # Edit MOD's image list
184
+ #
185
+ # @param mod_name [String] the MOD name
186
+ # @param image_ids [Array<String>] array of image IDs (SHA1 hashes)
187
+ # @return [void]
188
+ # @raise [MODNotOnPortalError] if MOD not found on portal
189
+ # @raise [HTTPClientError] for other 4xx errors
190
+ # @raise [HTTPServerError] for 5xx errors
191
+ def edit_images(mod_name, image_ids)
192
+ raise ArgumentError, "image_ids must be an array" unless image_ids.is_a?(Array)
193
+
194
+ uri = URI.join(BASE_URL, "/api/v2/mods/images/edit")
195
+
196
+ form_data = {mod: mod_name, images: image_ids.join(",")}
197
+ body = URI.encode_www_form(form_data)
198
+
199
+ logger.info("Editing MOD images", mod: mod_name, image_count: image_ids.size)
200
+ client.post(uri, body:, headers: build_auth_header, content_type: "application/x-www-form-urlencoded")
201
+ logger.info("Images updated successfully", mod: mod_name)
202
+ publish("mod.changed", mod: mod_name)
203
+ rescue HTTPNotFoundError => e
204
+ raise MODNotOnPortalError, e.api_message || "MOD '#{mod_name}' not found on portal"
205
+ end
206
+
207
+ private def api_credential
208
+ return @api_credential if defined?(@api_credential)
209
+
210
+ @api_credential_mutex.synchronize do
211
+ @api_credential ||= Application[:api_credential]
212
+ end
213
+ end
214
+
215
+ private def build_auth_header
216
+ {"Authorization" => "Bearer #{api_credential.api_key}"}
217
+ end
218
+
219
+ private def validate_metadata!(metadata, allowed_keys, context)
220
+ return if metadata.empty?
221
+
222
+ invalid_keys = metadata.keys.map(&:to_s) - allowed_keys
223
+ return if invalid_keys.empty?
224
+
225
+ raise ArgumentError, "Invalid metadata for #{context}: #{invalid_keys.join(", ")}. Allowed keys: #{allowed_keys.join(", ")}"
226
+ end
227
+
228
+ private def parse_upload_url(response)
229
+ data = JSON.parse(response.body)
230
+ url_string = data["upload_url"] or raise HTTPError, "Missing upload_url in response"
231
+ URI(url_string)
232
+ rescue JSON::ParserError => e
233
+ raise HTTPError, "Invalid JSON response: #{e.message}"
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+ require "tempfile"
6
+ require "uri"
7
+
8
+ module Factorix
9
+ module API
10
+ # API client for retrieving MOD list and details without authentication
11
+ #
12
+ # Corresponds to: https://wiki.factorio.com/Mod_portal_API
13
+ class MODPortalAPI
14
+ # @!parse
15
+ # # @return [Dry::Logger::Dispatcher]
16
+ # attr_reader :logger
17
+ # # @return [Cache::FileSystem]
18
+ # attr_reader :cache
19
+ # # @return [HTTP::Client]
20
+ # attr_reader :client
21
+ include Import[:logger, cache: :api_cache, client: :api_http_client]
22
+
23
+ BASE_URL = "https://mods.factorio.com"
24
+ private_constant :BASE_URL
25
+
26
+ # Retrieve MOD list with optional filters
27
+ #
28
+ # @param namelist [Array<String>] MOD names to filter (positional arguments, sorted for cache consistency)
29
+ # @param hide_deprecated [Boolean, nil] hide deprecated MODs
30
+ # @param page [Integer, nil] page number (1-based)
31
+ # @param page_size [Integer, String, nil] number of results per page (positive integer or "max")
32
+ # @param sort [String, nil] sort field (name, created_at, updated_at)
33
+ # @param sort_order [String, nil] sort order (asc, desc)
34
+ # @param version [String, nil] Factorio version filter
35
+ # @return [Hash{Symbol => untyped}] parsed JSON response with :results and :pagination keys
36
+ def get_mods(*namelist, hide_deprecated: nil, page: nil, page_size: nil, sort: nil, sort_order: nil, version: nil)
37
+ validate_page_size!(page_size) if page_size
38
+ validate_sort!(sort) if sort
39
+ validate_sort_order!(sort_order) if sort_order
40
+ validate_version!(version) if version
41
+
42
+ params = {namelist: namelist.sort, hide_deprecated:, page:, page_size:, sort:, sort_order:, version:}
43
+ params.reject! {|_k, v| v.is_a?(Array) && v.empty? }
44
+ params.compact!
45
+ logger.debug "Fetching MOD list: params=#{params.inspect}"
46
+ uri = build_uri("/api/mods", **params)
47
+ fetch_with_cache(uri)
48
+ end
49
+
50
+ # Retrieve basic information for a specific MOD
51
+ #
52
+ # @param name [String] MOD name
53
+ # @return [Hash{Symbol => untyped}] parsed JSON response with MOD metadata and releases
54
+ # @raise [MODNotOnPortalError] if MOD not found on portal
55
+ def get_mod(name)
56
+ logger.debug "Fetching MOD: name=#{name}"
57
+ encoded_name = ERB::Util.url_encode(name)
58
+ uri = build_uri("/api/mods/#{encoded_name}")
59
+ fetch_with_cache(uri)
60
+ rescue HTTPNotFoundError => e
61
+ raise MODNotOnPortalError, e.api_message || "MOD '#{name}' not found on portal"
62
+ end
63
+
64
+ # Retrieve detailed information for a specific MOD
65
+ #
66
+ # @param name [String] MOD name
67
+ # @return [Hash{Symbol => untyped}] parsed JSON response with full MOD details including dependencies
68
+ # @raise [MODNotOnPortalError] if MOD not found on portal
69
+ def get_mod_full(name)
70
+ logger.debug "Fetching full MOD info: name=#{name}"
71
+ encoded_name = ERB::Util.url_encode(name)
72
+ uri = build_uri("/api/mods/#{encoded_name}/full")
73
+ fetch_with_cache(uri)
74
+ rescue HTTPNotFoundError => e
75
+ raise MODNotOnPortalError, e.api_message || "MOD '#{name}' not found on portal"
76
+ end
77
+
78
+ # Event handler for mod.changed event
79
+ # Invalidates cached MOD information when a MOD is modified on the portal
80
+ #
81
+ # @param event [Dry::Events::Event] event with mod payload
82
+ # @return [void]
83
+ def on_mod_changed(event)
84
+ mod_name = event[:mod]
85
+ encoded_name = ERB::Util.url_encode(mod_name)
86
+
87
+ # Invalidate get_mod cache
88
+ mod_uri = build_uri("/api/mods/#{encoded_name}")
89
+ mod_key = cache.key_for(mod_uri.to_s)
90
+ cache.with_lock(mod_key) { cache.delete(mod_key) }
91
+
92
+ # Invalidate get_mod_full cache
93
+ full_uri = build_uri("/api/mods/#{encoded_name}/full")
94
+ full_key = cache.key_for(full_uri.to_s)
95
+ cache.with_lock(full_key) { cache.delete(full_key) }
96
+
97
+ logger.debug("Invalidated cache for MOD", mod: mod_name)
98
+ end
99
+
100
+ private def build_uri(path, **params)
101
+ URI.join(BASE_URL, path).tap {|uri| uri.query = URI.encode_www_form(params.sort.to_h) unless params.empty? }
102
+ end
103
+
104
+ # Fetch data with cache support
105
+ #
106
+ # @param uri [URI::HTTPS] URI to fetch
107
+ # @return [Hash{Symbol => untyped}] parsed JSON response with symbolized keys
108
+ private def fetch_with_cache(uri)
109
+ key = cache.key_for(uri.to_s)
110
+
111
+ cached = cache.read(key, encoding: "UTF-8")
112
+ if cached
113
+ logger.debug("API cache hit", uri: uri.to_s)
114
+ return JSON.parse(cached, symbolize_names: true)
115
+ end
116
+
117
+ logger.debug("API cache miss", uri: uri.to_s)
118
+ response_body = fetch_from_api(uri)
119
+
120
+ store_in_cache(key, response_body)
121
+
122
+ JSON.parse(response_body, symbolize_names: true)
123
+ end
124
+
125
+ # Fetch data from API via HTTP
126
+ #
127
+ # @param uri [URI::HTTPS] URI to fetch
128
+ # @return [String] response body
129
+ # @raise [HTTPClientError] for 4xx errors
130
+ # @raise [HTTPServerError] for 5xx errors
131
+ private def fetch_from_api(uri)
132
+ logger.info("Fetching from API", uri: uri.to_s)
133
+ response = client.get(uri)
134
+ logger.info("API response", code: response.code, size_bytes: response.body.bytesize)
135
+ response.body
136
+ end
137
+
138
+ # Store response body in cache via temporary file
139
+ #
140
+ # @param key [String] cache key
141
+ # @param data [String] response body
142
+ # @return [void]
143
+ private def store_in_cache(key, data)
144
+ temp_file = Tempfile.new("cache")
145
+ begin
146
+ temp_file.write(data)
147
+ temp_file.close
148
+ cache.store(key, Pathname(temp_file.path))
149
+ logger.debug("Stored API response in cache", key:)
150
+ ensure
151
+ temp_file.unlink
152
+ end
153
+ end
154
+
155
+ # Validate page_size parameter
156
+ #
157
+ # @param page_size [Integer, String] page size value
158
+ # @return [void]
159
+ # @raise [ArgumentError] if page_size is invalid
160
+ private def validate_page_size!(page_size)
161
+ return if page_size == "max"
162
+ return if page_size.is_a?(Integer) && page_size.positive?
163
+
164
+ raise ArgumentError, "page_size must be a positive integer or 'max', got: #{page_size.inspect}"
165
+ end
166
+
167
+ # Validate sort parameter
168
+ #
169
+ # @param sort [String] sort field name
170
+ # @return [void]
171
+ # @raise [ArgumentError] if sort is invalid
172
+ private def validate_sort!(sort)
173
+ valid_sorts = %w[name created_at updated_at]
174
+ return if valid_sorts.include?(sort)
175
+
176
+ raise ArgumentError, "sort must be one of #{valid_sorts.join(", ")}, got: #{sort.inspect}"
177
+ end
178
+
179
+ # Validate sort_order parameter
180
+ #
181
+ # @param sort_order [String] sort order
182
+ # @return [void]
183
+ # @raise [ArgumentError] if sort_order is invalid
184
+ private def validate_sort_order!(sort_order)
185
+ valid_orders = %w[asc desc]
186
+ return if valid_orders.include?(sort_order)
187
+
188
+ raise ArgumentError, "sort_order must be one of #{valid_orders.join(", ")}, got: #{sort_order.inspect}"
189
+ end
190
+
191
+ # Validate version parameter
192
+ #
193
+ # @param version [String] Factorio version
194
+ # @return [void]
195
+ # @raise [ArgumentError] if version is invalid
196
+ private def validate_version!(version)
197
+ valid_versions = %w[0.13 0.14 0.15 0.16 0.17 0.18 1.0 1.1 2.0]
198
+ return if valid_versions.include?(version)
199
+
200
+ raise ArgumentError, "version must be one of #{valid_versions.join(", ")}, got: #{version.inspect}"
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "uri"
5
+
6
+ module Factorix
7
+ module API
8
+ Release = Data.define(:download_url, :file_name, :info_json, :released_at, :version, :sha1, :feature_flags)
9
+
10
+ # Release object from MOD Portal API
11
+ #
12
+ # Represents a specific version/release of a MOD
13
+ #
14
+ # @see https://wiki.factorio.com/Mod_portal_API#Releases
15
+ class Release
16
+ # @!attribute [r] download_url
17
+ # @return [URI::HTTPS] absolute URL for downloading this release
18
+ # @!attribute [r] file_name
19
+ # @return [String] file name of the release archive
20
+ # @!attribute [r] info_json
21
+ # @return [Hash] info.json metadata from the MOD
22
+ # @!attribute [r] released_at
23
+ # @return [Time] release timestamp in UTC
24
+ # @!attribute [r] version
25
+ # @return [MODVersion] MOD version object
26
+ # @!attribute [r] sha1
27
+ # @return [String] SHA1 checksum of the release file
28
+ # @!attribute [r] feature_flags
29
+ # @return [Array<String>] list of enabled feature flags
30
+
31
+ # Create Release from API response hash
32
+ #
33
+ # @param download_url [String] relative download URL path
34
+ # @param file_name [String] release file name
35
+ # @param info_json [Hash] info.json metadata
36
+ # @param released_at [String] ISO 8601 timestamp
37
+ # @param version [String] version string in "X.Y.Z" format
38
+ # @param sha1 [String] SHA1 checksum
39
+ # @param feature_flags [Array<String>] list of enabled feature flags (defaults to empty array)
40
+ # @return [Release] new Release instance
41
+ def initialize(download_url:, file_name:, info_json:, released_at:, version:, sha1:, feature_flags: [])
42
+ download_url = URI("https://mods.factorio.com#{download_url}")
43
+ released_at = Time.parse(released_at).utc
44
+ version = MODVersion.from_string(version)
45
+ super
46
+ end
47
+ end
48
+ end
49
+ end