require_relative 'module' module Deliver # upload description, rating, etc. # rubocop:disable Metrics/ClassLength class UploadMetadata # All the localised values attached to the version LOCALISED_VERSION_VALUES = { description: "description", keywords: "keywords", release_notes: "whatsNew", support_url: "supportUrl", marketing_url: "marketingUrl", promotional_text: "promotionalText" } # Everything attached to the version but not being localised NON_LOCALISED_VERSION_VALUES = { copyright: "copyright" } # Localised app details values LOCALISED_APP_VALUES = { name: "name", subtitle: "subtitle", privacy_url: "privacyPolicyUrl", apple_tv_privacy_policy: "privacyPolicyText" } # Non localized app details values NON_LOCALISED_APP_VALUES = [:primary_category, :secondary_category, :primary_first_sub_category, :primary_second_sub_category, :secondary_first_sub_category, :secondary_second_sub_category] # Review information values REVIEW_INFORMATION_VALUES_LEGACY = { review_first_name: :first_name, review_last_name: :last_name, review_phone_number: :phone_number, review_email: :email_address, review_demo_user: :demo_user, review_demo_password: :demo_password, review_notes: :notes } REVIEW_INFORMATION_VALUES = { first_name: "contactFirstName", last_name: "contactLastName", phone_number: "contactPhone", email_address: "contactEmail", demo_user: "demoAccountName", demo_password: "demoAccountPassword", notes: "notes" } # Localized app details values, that are editable in live state LOCALISED_LIVE_VALUES = [:description, :release_notes, :support_url, :marketing_url, :promotional_text, :privacy_url] # Non localized app details values, that are editable in live state NON_LOCALISED_LIVE_VALUES = [:copyright] # Directory name it contains trade representative contact information TRADE_REPRESENTATIVE_CONTACT_INFORMATION_DIR = "trade_representative_contact_information" # Directory name it contains review information REVIEW_INFORMATION_DIR = "review_information" ALL_META_SUB_DIRS = [TRADE_REPRESENTATIVE_CONTACT_INFORMATION_DIR, REVIEW_INFORMATION_DIR] # rubocop:disable Metrics/PerceivedComplexity require_relative 'loader' # Make sure to call `load_from_filesystem` before calling upload def upload(options) return if options[:skip_metadata] legacy_app = options[:app] app_id = legacy_app.apple_id app = Spaceship::ConnectAPI::App.get(app_id: app_id) platform = Spaceship::ConnectAPI::Platform.map(options[:platform]) app_store_version_localizations = verify_available_version_languages!(options, app) unless options[:edit_live] app_info_localizations = verify_available_info_languages!(options, app) unless options[:edit_live] if options[:edit_live] # not all values are editable when using live_version version = app.get_live_app_store_version(platform: platform) localised_options = LOCALISED_LIVE_VALUES non_localised_options = NON_LOCALISED_LIVE_VALUES if v.nil? UI.message("Couldn't find live version, editing the current version on App Store Connect instead") version = app.get_edit_app_store_version(platform: platform) # we don't want to update the localised_options and non_localised_options # as we also check for `options[:edit_live]` at other areas in the code # by not touching those 2 variables, deliver is more consistent with what the option says # in the documentation else UI.message("Found live version") end else version = app.get_edit_app_store_version(platform: platform) localised_options = (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys) non_localised_options = NON_LOCALISED_VERSION_VALUES.keys end # Needed for to filter out release notes from being sent up is_first_version = app.get_live_app_store_version(platform: platform).nil? UI.important("Will begin uploading metadata for '#{version.version_string}' on App Store Connect") localized_version_attributes_by_locale = {} localized_info_attributes_by_locale = {} localised_options.each do |key| current = options[key] next unless current unless current.kind_of?(Hash) UI.error("Error with provided '#{key}'. Must be a hash, the key being the language.") next end if key == :release_notes && is_first_version UI.error("Skipping 'release_notes'... this is the first version of the app") next end current.each do |language, value| next unless value.to_s.length > 0 strip_value = value.to_s.strip if LOCALISED_VERSION_VALUES.include?(key) && !strip_value.empty? attribute_name = LOCALISED_VERSION_VALUES[key] localized_version_attributes_by_locale[language] ||= {} localized_version_attributes_by_locale[language][attribute_name] = strip_value end next unless LOCALISED_APP_VALUES.include?(key) && !strip_value.empty? attribute_name = LOCALISED_APP_VALUES[key] localized_info_attributes_by_locale[language] ||= {} localized_info_attributes_by_locale[language][attribute_name] = strip_value end end non_localized_version_attributes = {} non_localised_options.each do |key| strip_value = options[key].to_s.strip next unless strip_value.to_s.length > 0 if NON_LOCALISED_VERSION_VALUES.include?(key) && !strip_value.empty? attribute_name = NON_LOCALISED_VERSION_VALUES[key] non_localized_version_attributes[attribute_name] = strip_value end end release_type = if options[:auto_release_date] non_localized_version_attributes['earliestReleaseDate'] = options[:auto_release_date] Spaceship::ConnectAPI::AppStoreVersion::ReleaseType::SCHEDULED elsif options[:automatic_release] Spaceship::ConnectAPI::AppStoreVersion::ReleaseType::AFTER_APPROVAL else Spaceship::ConnectAPI::AppStoreVersion::ReleaseType::MANUAL end non_localized_version_attributes['releaseType'] = release_type # Update app store version localizations app_store_version_localizations.each do |app_store_version_localization| attributes = localized_version_attributes_by_locale[app_store_version_localization.locale] if attributes UI.message("Uploading metadata to App Store Connect for localized version '#{app_store_version_localization.locale}'") app_store_version_localization.update(attributes: attributes) end end # Update app info localizations app_info_localizations.each do |app_info_localization| attributes = localized_info_attributes_by_locale[app_info_localization.locale] if attributes UI.message("Uploading metadata to App Store Connect for localized info '#{app_info_localization.locale}'") app_info_localization.update(attributes: attributes) end end # Update app store version UI.message("Uploading metadata to App Store Connect for version") version.update(attributes: non_localized_version_attributes) # Update categories app_info = app.fetch_edit_app_info if app_info app_info.update_categories( primary_category_id: Spaceship::ConnectAPI::AppCategory.map_category_from_itc( options[:primary_category].to_s.strip ), secondary_category_id: Spaceship::ConnectAPI::AppCategory.map_category_from_itc( options[:secondary_category].to_s.strip ), primary_subcategory_one_id: Spaceship::ConnectAPI::AppCategory.map_subcategory_from_itc( options[:primary_first_sub_category].to_s.strip ), primary_subcategory_two_id: Spaceship::ConnectAPI::AppCategory.map_subcategory_from_itc( options[:primary_second_sub_category].to_s.strip ), secondary_subcategory_one_id: Spaceship::ConnectAPI::AppCategory.map_subcategory_from_itc( options[:secondary_first_sub_category].to_s.strip ), secondary_subcategory_two_id: Spaceship::ConnectAPI::AppCategory.map_subcategory_from_itc( options[:secondary_second_sub_category].to_s.strip ) ) end # Update phased release unless options[:phased_release].nil? phased_release = begin version.fetch_app_store_version_phased_release rescue nil end # returns no data error so need to rescue if !!options[:phased_release] unless phased_release UI.message("Creating phased release on App Store Connect") version.create_app_store_version_phased_release(attributes: { phasedReleaseState: Spaceship::ConnectAPI::AppStoreVersionPhasedRelease::PhasedReleaseState::INACTIVE }) end elsif phased_release UI.message("Removing phased release on App Store Connect") phased_release.delete! end end # Update rating reset unless options[:reset_ratings].nil? reset_rating_request = begin version.fetch_reset_ratings_request rescue nil end # returns no data error so need to rescue if !!options[:reset_ratings] unless reset_rating_request UI.message("Creating reset ratings request on App Store Connect") version.create_reset_ratings_request end elsif reset_rating_request UI.message("Removing reset ratings request on App Store Connect") reset_rating_request.delete! end end set_review_information(version, options) set_review_attachment_file(version, options) set_app_rating(version, options) end # rubocop:enable Metrics/PerceivedComplexity # If the user is using the 'default' language, then assign values where they are needed def assign_defaults(options) # Normalizes languages keys from symbols to strings normalize_language_keys(options) # Build a complete list of the required languages enabled_languages = detect_languages(options) # Get all languages used in existing settings (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key| current = options[key] next unless current && current.kind_of?(Hash) current.each do |language, value| enabled_languages << language unless enabled_languages.include?(language) end end # Check folder list (an empty folder signifies a language is required) ignore_validation = options[:ignore_language_directory_validation] Loader.language_folders(options[:metadata_path], ignore_validation).each do |lang_folder| next unless File.directory?(lang_folder) # We don't want to read txt as they are non localised language = File.basename(lang_folder) enabled_languages << language unless enabled_languages.include?(language) end return unless enabled_languages.include?("default") UI.message("Detected languages: " + enabled_languages.to_s) (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key| current = options[key] next unless current && current.kind_of?(Hash) default = current["default"] next if default.nil? enabled_languages.each do |language| value = current[language] next unless value.nil? current[language] = default end current.delete("default") end end def detect_languages(options) # Build a complete list of the required languages enabled_languages = options[:languages] || [] # Get all languages used in existing settings (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key| current = options[key] next unless current && current.kind_of?(Hash) current.each do |language, value| enabled_languages << language unless enabled_languages.include?(language) end end # Check folder list (an empty folder signifies a language is required) ignore_validation = options[:ignore_language_directory_validation] Loader.language_folders(options[:metadata_path], ignore_validation).each do |lang_folder| next unless File.directory?(lang_folder) # We don't want to read txt as they are non localised language = File.basename(lang_folder) enabled_languages << language unless enabled_languages.include?(language) end # Mapping to strings because :default symbol can be passed in enabled_languages .map(&:to_s) .uniq end # Finding languages to enable def verify_available_info_languages!(options, app) app_info = app.fetch_edit_app_info unless app_info UI.user_error!("Cannot update languages - could not find an editable info") return end localizations = app_info.get_app_info_localizations languages = options[:languages] || [] locales_to_enable = languages - localizations.map(&:locale) if locales_to_enable.count > 0 lng_text = "language" lng_text += "s" if locales_to_enable.count != 1 Helper.show_loading_indicator("Activating info #{lng_text} #{locales_to_enable.join(', ')}...") locales_to_enable.each do |locale| app_info.create_app_info_localization(attributes: { locale: locale }) end Helper.hide_loading_indicator # Refresh version localizations localizations = app_info.get_app_info_localizations end return localizations end # Finding languages to enable def verify_available_version_languages!(options, app) platform = Spaceship::ConnectAPI::Platform.map(options[:platform]) version = app.get_edit_app_store_version(platform: platform) unless version UI.user_error!("Cannot update languages - could not find an editable version for '#{platform}'") return end localizations = version.get_app_store_version_localizations languages = options[:languages] || [] locales_to_enable = languages - localizations.map(&:locale) if locales_to_enable.count > 0 lng_text = "language" lng_text += "s" if locales_to_enable.count != 1 Helper.show_loading_indicator("Activating version #{lng_text} #{locales_to_enable.join(', ')}...") locales_to_enable.each do |locale| version.create_app_store_version_localization(attributes: { locale: locale }) end Helper.hide_loading_indicator # Refresh version localizations localizations = version.get_app_store_version_localizations end return localizations end # Loads the metadata files and stores them into the options object def load_from_filesystem(options) return if options[:skip_metadata] # Load localised data ignore_validation = options[:ignore_language_directory_validation] Loader.language_folders(options[:metadata_path], ignore_validation).each do |lang_folder| language = File.basename(lang_folder) (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key| path = File.join(lang_folder, "#{key}.txt") next unless File.exist?(path) UI.message("Loading '#{path}'...") options[key] ||= {} options[key][language] ||= File.read(path) end end # Load non localised data (NON_LOCALISED_VERSION_VALUES.keys + NON_LOCALISED_APP_VALUES).each do |key| path = File.join(options[:metadata_path], "#{key}.txt") next unless File.exist?(path) UI.message("Loading '#{path}'...") options[key] ||= File.read(path) end # Load review information options[:app_review_information] ||= {} REVIEW_INFORMATION_VALUES.keys.each do |option_name| path = File.join(options[:metadata_path], REVIEW_INFORMATION_DIR, "#{option_name}.txt") next unless File.exist?(path) next if options[:app_review_information][option_name].to_s.length > 0 UI.message("Loading '#{path}'...") options[:app_review_information][option_name] ||= File.read(path) end end private # Normalizes languages keys from symbols to strings def normalize_language_keys(options) (LOCALISED_VERSION_VALUES.keys + LOCALISED_APP_VALUES.keys).each do |key| current = options[key] next unless current && current.kind_of?(Hash) current.keys.each do |language| current[language.to_s] = current.delete(language) end end options end def set_review_information(version, options) return unless options[:app_review_information] info = options[:app_review_information] UI.user_error!("`app_review_information` must be a hash", show_github_issues: true) unless info.kind_of?(Hash) attributes = {} REVIEW_INFORMATION_VALUES.each do |key, attribute_name| strip_value = info[key].to_s.strip attributes[attribute_name] = strip_value unless strip_value.empty? end if !attributes["demoAccountName"].to_s.empty? && !attributes["demoAccountPassword"].to_s.empty? attributes["demoAccountRequired"] = true else attributes["demoAccountRequired"] = false end UI.message("Uploading app review information to App Store Connect") app_store_review_detail = begin version.fetch_app_store_review_detail rescue nil end # errors if doesn't exist if app_store_review_detail app_store_review_detail.update(attributes: attributes) else version.create_app_store_review_detail(attributes: attributes) end end def set_review_attachment_file(version, options) app_store_review_detail = version.fetch_app_store_review_detail app_review_attachments = app_store_review_detail.fetch_app_review_attachments if options[:app_review_attachment_file] app_review_attachments.each do |app_review_attachment| UI.message("Removing previous review attachment file from App Store Connect") app_review_attachment.delete! end UI.message("Uploading review attachment file to App Store Connect") app_store_review_detail.upload_attachment(path: options[:app_review_attachment_file]) else app_review_attachments.each(&:delete!) UI.message("Removing review attachment file to App Store Connect") unless app_review_attachments.empty? end end def set_app_rating(version, options) return unless options[:app_rating_config_path] require 'json' begin json = JSON.parse(File.read(options[:app_rating_config_path])) rescue => ex UI.error(ex.to_s) UI.user_error!("Error parsing JSON file at path '#{options[:app_rating_config_path]}'") end UI.message("Setting the app's age rating...") # Maping from legacy ITC values to App Store Connect Values attributes = {} json.each do |k, v| new_key = Spaceship::ConnectAPI::AgeRatingDeclaration.map_key_from_itc(k) new_value = Spaceship::ConnectAPI::AgeRatingDeclaration.map_value_from_itc(new_key, v) attributes[new_key] = new_value end age_rating_delcaration = version.fetch_age_rating_declaration age_rating_delcaration.update(attributes: attributes) end end # rubocop:enable Metrics/ClassLength end