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,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
|