add batch_preview endpoint for entity import

This commit is contained in:
Justin Edmund 2025-12-01 23:39:49 -08:00
parent 707c0436c5
commit f5760b1833
6 changed files with 607 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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]

View file

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