diff --git a/app/controllers/api/v1/characters_controller.rb b/app/controllers/api/v1/characters_controller.rb index 9b3e590..e084a84 100644 --- a/app/controllers/api/v1/characters_controller.rb +++ b/app/controllers/api/v1/characters_controller.rb @@ -4,9 +4,10 @@ module Api module V1 class CharactersController < Api::V1::ApiController include IdResolvable + include BatchPreviewable - before_action :set, only: %i[show related download_images download_status update] - before_action :ensure_editor_role, only: %i[create update validate download_images] + before_action :set, only: %i[show related download_image download_images download_status update raw fetch_wiki] + before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview] # GET /characters/:id def show @@ -68,6 +69,53 @@ module Api end end + # POST /characters/:id/download_image + # Synchronously downloads a single image for a character + def download_image + size = params[:size] + transformation = params[:transformation] + force = params[:force] == true + + # Validate size + valid_sizes = Granblue::Downloaders::CharacterDownloader::SIZES + unless valid_sizes.include?(size) + return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity + end + + # Validate transformation for characters (01, 02, 03, 04) + valid_transformations = %w[01 02 03 04] + if transformation.present? && !valid_transformations.include?(transformation) + return render json: { error: "Invalid transformation. Must be one of: #{valid_transformations.join(', ')}" }, status: :unprocessable_entity + end + + # Build variant ID + variant_id = transformation.present? ? "#{@character.granblue_id}_#{transformation}" : "#{@character.granblue_id}_01" + + begin + downloader = Granblue::Downloaders::CharacterDownloader.new( + @character.granblue_id, + storage: :s3, + force: force, + verbose: true + ) + + # Call the download_variant method directly for a single variant/size + downloader.send(:download_variant, variant_id, size) + + render json: { + success: true, + character_id: @character.id, + granblue_id: @character.granblue_id, + size: size, + transformation: transformation, + message: 'Image downloaded successfully' + } + rescue StandardError => e + Rails.logger.error "[CHARACTERS] Image download error for #{@character.id}: #{e.message}" + render json: { success: false, error: e.message }, status: :internal_server_error + end + end + # POST /characters/:id/download_images # Triggers async image download for a character def download_images @@ -105,6 +153,59 @@ module Api ) end + # GET /characters/:id/raw + # Returns raw wiki and game data for database viewing + def raw + render json: CharacterBlueprint.render(@character, view: :raw) + end + + # POST /characters/batch_preview + # Fetches wiki data and suggestions for multiple wiki page names + def batch_preview + wiki_pages = params[:wiki_pages] + + unless wiki_pages.is_a?(Array) && wiki_pages.any? + return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity + end + + # Limit to 10 pages + wiki_pages = wiki_pages.first(10) + + results = wiki_pages.map do |wiki_page| + process_wiki_preview(wiki_page, :character) + end + + render json: { results: results } + end + + # POST /characters/:id/fetch_wiki + # Fetches and stores wiki data for this character + def fetch_wiki + unless @character.wiki_en.present? + return render json: { error: 'No wiki page configured for this character' }, status: :unprocessable_entity + end + + begin + wiki_text = Granblue::Parsers::Wiki.new.fetch(@character.wiki_en) + + # Handle redirects + redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/) + if redirect_match + redirect_target = redirect_match[1] + @character.update!(wiki_en: redirect_target) + wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target) + end + + @character.update!(wiki_raw: wiki_text) + render json: CharacterBlueprint.render(@character, view: :raw) + rescue Granblue::WikiError => e + render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway + rescue StandardError => e + Rails.logger.error "[CHARACTERS] Wiki fetch error for #{@character.id}: #{e.message}" + render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway + end + end + private def set diff --git a/app/controllers/api/v1/summons_controller.rb b/app/controllers/api/v1/summons_controller.rb index 68cdf80..799657d 100644 --- a/app/controllers/api/v1/summons_controller.rb +++ b/app/controllers/api/v1/summons_controller.rb @@ -4,9 +4,10 @@ module Api module V1 class SummonsController < Api::V1::ApiController include IdResolvable + include BatchPreviewable - before_action :set, only: %i[show download_images download_status update] - before_action :ensure_editor_role, only: %i[create update validate download_images] + before_action :set, only: %i[show download_image download_images download_status update raw fetch_wiki] + before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview] # GET /summons/:id def show @@ -59,6 +60,53 @@ module Api end end + # POST /summons/:id/download_image + # Synchronously downloads a single image for a summon + def download_image + size = params[:size] + transformation = params[:transformation] + force = params[:force] == true + + # Validate size + valid_sizes = Granblue::Downloaders::SummonDownloader::SIZES + unless valid_sizes.include?(size) + return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity + end + + # Validate transformation for summons (none, 02, 03, 04) + valid_transformations = [nil, '', '02', '03', '04'] + if transformation.present? && !valid_transformations.include?(transformation) + return render json: { error: 'Invalid transformation. Must be one of: 02, 03, 04 (or empty for base)' }, status: :unprocessable_entity + end + + # Build variant ID - summons don't have suffix for base + variant_id = transformation.present? ? "#{@summon.granblue_id}_#{transformation}" : @summon.granblue_id + + begin + downloader = Granblue::Downloaders::SummonDownloader.new( + @summon.granblue_id, + storage: :s3, + force: force, + verbose: true + ) + + # Call the download_variant method directly for a single variant/size + downloader.send(:download_variant, variant_id, size) + + render json: { + success: true, + summon_id: @summon.id, + granblue_id: @summon.granblue_id, + size: size, + transformation: transformation, + message: 'Image downloaded successfully' + } + rescue StandardError => e + Rails.logger.error "[SUMMONS] Image download error for #{@summon.id}: #{e.message}" + render json: { success: false, error: e.message }, status: :internal_server_error + end + end + # POST /summons/:id/download_images # Triggers async image download for a summon def download_images @@ -96,6 +144,59 @@ module Api ) end + # GET /summons/:id/raw + # Returns raw wiki and game data for database viewing + def raw + render json: SummonBlueprint.render(@summon, view: :raw) + end + + # POST /summons/batch_preview + # Fetches wiki data and suggestions for multiple wiki page names + def batch_preview + wiki_pages = params[:wiki_pages] + + unless wiki_pages.is_a?(Array) && wiki_pages.any? + return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity + end + + # Limit to 10 pages + wiki_pages = wiki_pages.first(10) + + results = wiki_pages.map do |wiki_page| + process_wiki_preview(wiki_page, :summon) + end + + render json: { results: results } + end + + # POST /summons/:id/fetch_wiki + # Fetches and stores wiki data for this summon + def fetch_wiki + unless @summon.wiki_en.present? + return render json: { error: 'No wiki page configured for this summon' }, status: :unprocessable_entity + end + + begin + wiki_text = Granblue::Parsers::Wiki.new.fetch(@summon.wiki_en) + + # Handle redirects + redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/) + if redirect_match + redirect_target = redirect_match[1] + @summon.update!(wiki_en: redirect_target) + wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target) + end + + @summon.update!(wiki_raw: wiki_text) + render json: SummonBlueprint.render(@summon, view: :raw) + rescue Granblue::WikiError => e + render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway + rescue StandardError => e + Rails.logger.error "[SUMMONS] Wiki fetch error for #{@summon.id}: #{e.message}" + render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway + end + end + private def set diff --git a/app/controllers/api/v1/weapons_controller.rb b/app/controllers/api/v1/weapons_controller.rb index cd21914..20fb2aa 100644 --- a/app/controllers/api/v1/weapons_controller.rb +++ b/app/controllers/api/v1/weapons_controller.rb @@ -4,9 +4,10 @@ module Api module V1 class WeaponsController < Api::V1::ApiController include IdResolvable + include BatchPreviewable - before_action :set, only: %i[show download_images download_status update] - before_action :ensure_editor_role, only: %i[create update validate download_images] + before_action :set, only: %i[show download_image download_images download_status update raw fetch_wiki] + before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview] # GET /weapons/:id def show @@ -59,6 +60,53 @@ module Api end end + # POST /weapons/:id/download_image + # Synchronously downloads a single image for a weapon + def download_image + size = params[:size] + transformation = params[:transformation] + force = params[:force] == true + + # Validate size + valid_sizes = Granblue::Downloaders::WeaponDownloader::SIZES + unless valid_sizes.include?(size) + return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity + end + + # Validate transformation for weapons (none, 02, 03) + valid_transformations = [nil, '', '02', '03'] + if transformation.present? && !valid_transformations.include?(transformation) + return render json: { error: 'Invalid transformation. Must be one of: 02, 03 (or empty for base)' }, status: :unprocessable_entity + end + + # Build variant ID - weapons don't have suffix for base + variant_id = transformation.present? ? "#{@weapon.granblue_id}_#{transformation}" : @weapon.granblue_id + + begin + downloader = Granblue::Downloaders::WeaponDownloader.new( + @weapon.granblue_id, + storage: :s3, + force: force, + verbose: true + ) + + # Call the download_variant method directly for a single variant/size + downloader.send(:download_variant, variant_id, size) + + render json: { + success: true, + weapon_id: @weapon.id, + granblue_id: @weapon.granblue_id, + size: size, + transformation: transformation, + message: 'Image downloaded successfully' + } + rescue StandardError => e + Rails.logger.error "[WEAPONS] Image download error for #{@weapon.id}: #{e.message}" + render json: { success: false, error: e.message }, status: :internal_server_error + end + end + # POST /weapons/:id/download_images # Triggers async image download for a weapon def download_images @@ -96,6 +144,59 @@ module Api ) end + # GET /weapons/:id/raw + # Returns raw wiki and game data for database viewing + def raw + render json: WeaponBlueprint.render(@weapon, view: :raw) + end + + # POST /weapons/batch_preview + # Fetches wiki data and suggestions for multiple wiki page names + def batch_preview + wiki_pages = params[:wiki_pages] + + unless wiki_pages.is_a?(Array) && wiki_pages.any? + return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity + end + + # Limit to 10 pages + wiki_pages = wiki_pages.first(10) + + results = wiki_pages.map do |wiki_page| + process_wiki_preview(wiki_page, :weapon) + end + + render json: { results: results } + end + + # POST /weapons/:id/fetch_wiki + # Fetches and stores wiki data for this weapon + def fetch_wiki + unless @weapon.wiki_en.present? + return render json: { error: 'No wiki page configured for this weapon' }, status: :unprocessable_entity + end + + begin + wiki_text = Granblue::Parsers::Wiki.new.fetch(@weapon.wiki_en) + + # Handle redirects + redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/) + if redirect_match + redirect_target = redirect_match[1] + @weapon.update!(wiki_en: redirect_target) + wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target) + end + + @weapon.update!(wiki_raw: wiki_text) + render json: WeaponBlueprint.render(@weapon, view: :raw) + rescue Granblue::WikiError => e + render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway + rescue StandardError => e + Rails.logger.error "[WEAPONS] Wiki fetch error for #{@weapon.id}: #{e.message}" + render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway + end + end + private def set diff --git a/app/controllers/concerns/batch_previewable.rb b/app/controllers/concerns/batch_previewable.rb new file mode 100644 index 0000000..324c6f1 --- /dev/null +++ b/app/controllers/concerns/batch_previewable.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# Provides batch wiki preview functionality for entity controllers +module BatchPreviewable + extend ActiveSupport::Concern + + private + + # Process a single wiki page and return preview data + # @param wiki_page [String] The wiki page name to fetch + # @param entity_type [Symbol] The type of entity (:character, :weapon, :summon) + # @return [Hash] Preview data including status, suggestions, and errors + def process_wiki_preview(wiki_page, entity_type) + result = { + wiki_page: wiki_page, + status: 'success' + } + + begin + # Fetch wiki content + wiki = Granblue::Parsers::Wiki.new + wiki_text = wiki.fetch(wiki_page) + + # Handle redirects + redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/) + if redirect_match + redirect_target = redirect_match[1] + result[:redirected_from] = wiki_page + result[:wiki_page] = redirect_target + wiki_text = wiki.fetch(redirect_target) + end + + result[:wiki_raw] = wiki_text + + # Parse suggestions based on entity type + suggestions = case entity_type + when :character + Granblue::Parsers::SuggestionParser.parse_character(wiki_text) + when :weapon + Granblue::Parsers::SuggestionParser.parse_weapon(wiki_text) + when :summon + Granblue::Parsers::SuggestionParser.parse_summon(wiki_text) + end + + result[:granblue_id] = suggestions[:granblue_id] if suggestions[:granblue_id].present? + result[:suggestions] = suggestions + + # Queue image download if we have a granblue_id + if suggestions[:granblue_id].present? + result[:image_status] = queue_image_download(suggestions[:granblue_id], entity_type) + else + result[:image_status] = 'no_id' + end + rescue Granblue::WikiError => e + result[:status] = 'error' + result[:error] = "Wiki page not found: #{e.message}" + rescue StandardError => e + Rails.logger.error "[BATCH_PREVIEW] Error processing #{wiki_page}: #{e.message}" + result[:status] = 'error' + result[:error] = "Failed to process wiki page: #{e.message}" + end + + result + end + + # Queue an image download job for the entity + # @param granblue_id [String] The granblue ID to download images for + # @param entity_type [Symbol] The type of entity + # @return [String] Status of the image download ('queued', 'skipped', 'error') + def queue_image_download(granblue_id, entity_type) + # Check if entity already exists in database + model_class = case entity_type + when :character then Character + when :weapon then Weapon + when :summon then Summon + end + + existing = model_class.find_by(granblue_id: granblue_id) + if existing + # Entity exists, skip download (images likely already exist) + return 'exists' + end + + # For now, we don't queue the download since the entity doesn't exist yet + # The image download will happen after the entity is created + 'pending' + rescue StandardError => e + Rails.logger.error "[BATCH_PREVIEW] Error queueing image download: #{e.message}" + 'error' + end +end diff --git a/config/routes.rb b/config/routes.rb index fadd72e..9879874 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,28 +15,40 @@ Rails.application.routes.draw do resources :weapons, only: %i[show create update] do collection do get 'validate/:granblue_id', action: :validate, as: :validate + post 'batch_preview' end member do + post 'download_image' post 'download_images' get 'download_status' + get 'raw' + post 'fetch_wiki' end end resources :characters, only: %i[show create update] do collection do get 'validate/:granblue_id', action: :validate, as: :validate + post 'batch_preview' end member do + post 'download_image' post 'download_images' get 'download_status' + get 'raw' + post 'fetch_wiki' end end resources :summons, only: %i[show create update] do collection do get 'validate/:granblue_id', action: :validate, as: :validate + post 'batch_preview' end member do + post 'download_image' post 'download_images' get 'download_status' + get 'raw' + post 'fetch_wiki' end end resources :favorites, only: [:create] diff --git a/lib/granblue/parsers/suggestion_parser.rb b/lib/granblue/parsers/suggestion_parser.rb new file mode 100644 index 0000000..ffc2666 --- /dev/null +++ b/lib/granblue/parsers/suggestion_parser.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +module Granblue + module Parsers + # SuggestionParser extracts structured suggestions from wiki text + # for use in batch entity import flows + class SuggestionParser + # Parse character wiki text into suggestion fields + def self.parse_character(wiki_text) + return {} if wiki_text.blank? + + data = parse_wiki_text(wiki_text) + + suggestions = {} + suggestions[:name_en] = data['name'] if data['name'].present? + suggestions[:name_jp] = data['jpname'] if data['jpname'].present? + suggestions[:granblue_id] = data['id'] if data['id'].present? + + # Character ID (for linking related characters) + if data['charid'].present? + char_ids = data['charid'].scan(/\b\d{4}\b/) + suggestions[:character_id] = char_ids if char_ids.any? + end + + # Rarity + suggestions[:rarity] = Wiki.rarities[data['rarity']] if data['rarity'].present? + + # Element + suggestions[:element] = Wiki.elements[data['element']] if data['element'].present? + + # Gender + suggestions[:gender] = Wiki.genders[data['gender']] if data['gender'].present? + + # Proficiencies + if data['weapon'].present? + profs = data['weapon'].split(',').map(&:strip) + suggestions[:proficiency1] = Wiki.proficiencies[profs[0]] if profs[0] + suggestions[:proficiency2] = Wiki.proficiencies[profs[1]] if profs[1] + end + + # Races + if data['race'].present? + races = data['race'].split(',').map(&:strip) + suggestions[:race1] = Wiki.races[races[0]] if races[0] + suggestions[:race2] = Wiki.races[races[1]] if races[1] + end + + # Stats + suggestions[:min_hp] = data['min_hp'].to_i if data['min_hp'].present? + suggestions[:max_hp] = data['max_hp'].to_i if data['max_hp'].present? + suggestions[:max_hp_flb] = data['flb_hp'].to_i if data['flb_hp'].present? + suggestions[:min_atk] = data['min_atk'].to_i if data['min_atk'].present? + suggestions[:max_atk] = data['max_atk'].to_i if data['max_atk'].present? + suggestions[:max_atk_flb] = data['flb_atk'].to_i if data['flb_atk'].present? + + # Uncap status + suggestions[:flb] = Wiki.boolean.fetch(data['5star'], false) if data['5star'].present? + suggestions[:ulb] = data['max_evo'].to_i == 6 if data['max_evo'].present? + + # Dates + suggestions[:release_date] = parse_date(data['release_date']) if data['release_date'].present? + suggestions[:flb_date] = parse_date(data['5star_date']) if data['5star_date'].present? + suggestions[:ulb_date] = parse_date(data['6star_date']) if data['6star_date'].present? + + # External links + suggestions[:gamewith] = data['link_gamewith'] if data['link_gamewith'].present? + suggestions[:kamigame] = data['link_kamigame'] if data['link_kamigame'].present? + + suggestions.compact + end + + # Parse weapon wiki text into suggestion fields + def self.parse_weapon(wiki_text) + return {} if wiki_text.blank? + + data = parse_wiki_text(wiki_text) + + suggestions = {} + suggestions[:name_en] = data['name'] if data['name'].present? + suggestions[:name_jp] = data['jpname'] if data['jpname'].present? + suggestions[:granblue_id] = data['id'] if data['id'].present? + + # Rarity + suggestions[:rarity] = Wiki.rarities[data['rarity']] if data['rarity'].present? + + # Element + suggestions[:element] = Wiki.elements[data['element']] if data['element'].present? + + # Proficiency (weapon type) + suggestions[:proficiency] = Wiki.proficiencies[data['type']] if data['type'].present? + + # Stats + suggestions[:min_hp] = data['min_hp'].to_i if data['min_hp'].present? + suggestions[:max_hp] = data['max_hp'].to_i if data['max_hp'].present? + suggestions[:max_hp_flb] = data['flb_hp'].to_i if data['flb_hp'].present? + suggestions[:min_atk] = data['min_atk'].to_i if data['min_atk'].present? + suggestions[:max_atk] = data['max_atk'].to_i if data['max_atk'].present? + suggestions[:max_atk_flb] = data['flb_atk'].to_i if data['flb_atk'].present? + + # Uncap status + suggestions[:flb] = Wiki.boolean.fetch(data['4star'], false) if data['4star'].present? + suggestions[:ulb] = Wiki.boolean.fetch(data['5star'], false) if data['5star'].present? + + # Dates + suggestions[:release_date] = parse_date(data['release_date']) if data['release_date'].present? + suggestions[:flb_date] = parse_date(data['4star_date']) if data['4star_date'].present? + suggestions[:ulb_date] = parse_date(data['5star_date']) if data['5star_date'].present? + + # External links + suggestions[:gamewith] = data['link_gamewith'] if data['link_gamewith'].present? + suggestions[:kamigame] = data['link_kamigame'] if data['link_kamigame'].present? + + # Recruits (character recruited by this weapon) + suggestions[:recruits] = data['recruit'] if data['recruit'].present? + + suggestions.compact + end + + # Parse summon wiki text into suggestion fields + def self.parse_summon(wiki_text) + return {} if wiki_text.blank? + + data = parse_wiki_text(wiki_text) + + suggestions = {} + suggestions[:name_en] = data['name'] if data['name'].present? + suggestions[:name_jp] = data['jpname'] if data['jpname'].present? + suggestions[:granblue_id] = data['id'] if data['id'].present? + + # Rarity + suggestions[:rarity] = Wiki.rarities[data['rarity']] if data['rarity'].present? + + # Element + suggestions[:element] = Wiki.elements[data['element']] if data['element'].present? + + # Stats + suggestions[:min_hp] = data['min_hp'].to_i if data['min_hp'].present? + suggestions[:max_hp] = data['max_hp'].to_i if data['max_hp'].present? + suggestions[:max_hp_flb] = data['flb_hp'].to_i if data['flb_hp'].present? + suggestions[:min_atk] = data['min_atk'].to_i if data['min_atk'].present? + suggestions[:max_atk] = data['max_atk'].to_i if data['max_atk'].present? + suggestions[:max_atk_flb] = data['flb_atk'].to_i if data['flb_atk'].present? + + # Uncap status + suggestions[:flb] = Wiki.boolean.fetch(data['4star'], false) if data['4star'].present? + suggestions[:ulb] = Wiki.boolean.fetch(data['5star'], false) if data['5star'].present? + + # Sub-aura + suggestions[:subaura] = Wiki.boolean.fetch(data['subaura'], false) if data['subaura'].present? + + # Dates + suggestions[:release_date] = parse_date(data['release_date']) if data['release_date'].present? + suggestions[:flb_date] = parse_date(data['4star_date']) if data['4star_date'].present? + suggestions[:ulb_date] = parse_date(data['5star_date']) if data['5star_date'].present? + + # External links + suggestions[:gamewith] = data['link_gamewith'] if data['link_gamewith'].present? + suggestions[:kamigame] = data['link_kamigame'] if data['link_kamigame'].present? + + suggestions.compact + end + + # Parse wiki text into a key-value hash + def self.parse_wiki_text(wiki_text) + lines = wiki_text.split("\n") + data = {} + stop_loop = false + + lines.each do |line| + next if stop_loop + + # Stop parsing at gameplay notes section + if line.include?('Gameplay Notes') + stop_loop = true + next + end + + next unless line[0] == '|' && line.size > 2 + + key, value = line[1..].split('=', 2).map(&:strip) + data[key] = value if value.present? + end + + data + end + + # Parse a date string into a Date object + def self.parse_date(date_str) + Date.parse(date_str) + rescue ArgumentError, TypeError + nil + end + end + end +end