diff --git a/.gitignore b/.gitignore index 410786f..08869e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Environment variables .env* +# Binary +batorment +batorment.exe + # Test files /app/tests/files/* diff --git a/Dockerfile b/Dockerfile index 54f9ef4..859db5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.25.5-bookworm AS builder +FROM golang:1.26.1-bookworm AS builder WORKDIR /app @@ -10,10 +10,8 @@ RUN go mod download # Copy source code COPY . . -# Build both binaries -RUN go build -o /app/bin/process_raid ./cmd/process_raid -RUN go build -o /app/bin/update_from_schaledb ./cmd/update_from_schaledb -RUN go build -o /app/bin/total_analysis ./cmd/total_analysis +# Build single binary +RUN go build -o /app/bin/batorment . # Runtime stage FROM debian:bookworm-slim @@ -26,10 +24,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -# Copy binaries from builder -COPY --from=builder /app/bin/process_raid /app/bin/process_raid -COPY --from=builder /app/bin/update_from_schaledb /app/bin/update_from_schaledb -COPY --from=builder /app/bin/total_analysis /app/bin/total_analysis +# Copy binary from builder +COPY --from=builder /app/bin/batorment /app/bin/batorment # Copy entrypoint script COPY docker-entrypoint.sh /app/docker-entrypoint.sh diff --git a/cmd/generate_student_grid_image.go b/cmd/generate_student_grid_image.go new file mode 100644 index 0000000..ec04554 --- /dev/null +++ b/cmd/generate_student_grid_image.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + + "ba-torment-data-process/internal/logic/gridimage" + "ba-torment-data-process/internal/ui" + + "github.com/spf13/cobra" +) + +var generateStudentGridImageCmd = &cobra.Command{ + Use: "generate-student-grid-image", + Short: "Generate student grid images", + RunE: func(cmd *cobra.Command, args []string) error { + dryRun, _ := cmd.Flags().GetBool("dry-run") + + if err := gridimage.GenerateGridImages(dryRun); err != nil { + return fmt.Errorf("failed to generate grid images: %w", err) + } + + ui.Log.Info("Successfully generated all grid images") + return nil + }, +} + +func init() { + generateStudentGridImageCmd.Flags().Bool("dry-run", false, "Save to local files/ directory instead of uploading") + rootCmd.AddCommand(generateStudentGridImageCmd) +} diff --git a/cmd/generate_student_grid_image/main.go b/cmd/generate_student_grid_image/main.go deleted file mode 100644 index 441e67d..0000000 --- a/cmd/generate_student_grid_image/main.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "flag" - "log" - - "ba-torment-data-process/internal/logic" - "ba-torment-data-process/internal/logic/gridimage" - - "github.com/joho/godotenv" -) - -func main() { - if logic.IsLocalEnv() { - if err := godotenv.Load(); err != nil { - log.Fatalf("Failed to load .env file: %v", err) - } - } - - dryRun := flag.Bool("dry-run", false, "Save to local files/ directory instead of uploading") - flag.Parse() - - if err := gridimage.GenerateGridImages(*dryRun); err != nil { - log.Fatalf("Failed to generate grid images: %v", err) - } - - log.Println("Successfully generated all grid images") -} diff --git a/cmd/process_raid.go b/cmd/process_raid.go new file mode 100644 index 0000000..28c5269 --- /dev/null +++ b/cmd/process_raid.go @@ -0,0 +1,228 @@ +package cmd + +import ( + "context" + "fmt" + + "ba-torment-data-process/internal/db/postgres" + "ba-torment-data-process/internal/logic/analysis" + "ba-torment-data-process/internal/logic/party" + "ba-torment-data-process/internal/logic/storage" + "ba-torment-data-process/internal/ui" + + gopostgres "github.com/BeaverHouse/go-common/database/postgres" + "github.com/BeaverHouse/go-common/logger" + "github.com/spf13/cobra" +) + +type raidListItem struct { + ID string `json:"id"` + Name string `json:"name"` + TopLevel string `json:"top_level"` + PartyUpdated bool `json:"party_updated"` +} + +var processRaidCmd = &cobra.Command{ + Use: "process-raid", + Short: "Process all raid content data", + RunE: func(cmd *cobra.Command, args []string) error { + dryRun, _ := cmd.Flags().GetBool("dry-run") + recent, _ := cmd.Flags().GetInt("recent") + + pool := gopostgres.InitFromEnv() + defer pool.Close() + + queries := postgres.New(pool) + + contents, err := queries.ListContentsForRaidList(context.Background()) + if err != nil { + return fmt.Errorf("failed to list contents: %w", err) + } + + partyUpdated := make(map[string]bool) + recentStart := len(contents) - recent + if recentStart < 0 { + recentStart = 0 + } + + // Older contents: download existing party JSON from S3, update video refs only + for _, content := range contents[:recentStart] { + contentID := content.ContentID + ui.Log.Info("Updating video refs only (cached)", logger.F("contentID", contentID)) + + partyData := analysis.DownloadPartyData(contentID) + if partyData == nil { + ui.Log.Warn("No cached party data found, skipping", logger.F("contentID", contentID)) + partyUpdated[contentID] = false + continue + } + partyUpdated[contentID] = true + + fileName := fmt.Sprintf("%s.json", contentID) + + updated, err := party.UpdateVideoRefWithData(partyData, contentID) + if err != nil { + ui.Log.Warn("Failed to update video refs", logger.F("contentID", contentID), logger.F("error", err)) + } else { + ui.Log.Info("Updated video references", logger.F("count", updated), logger.F("contentID", contentID)) + } + + if err := storage.MarshalAndUpload(partyData, "batorment/v3/party", fileName, dryRun, ""); err != nil { + ui.Log.Warn("Failed to upload party data", logger.F("error", err)) + } + + videoFilter := party.CreateVideoFilter(contentID) + if videoFilter != nil { + if err := storage.MarshalAndUpload(videoFilter, "batorment/v3/video-filter", fileName, dryRun, ""); err != nil { + ui.Log.Warn("Failed to upload video filter", logger.F("error", err)) + } + } + } + + // Recent contents: full DuckDB parsing + all processing + for _, content := range contents[recentStart:] { + contentID := content.ContentID + ui.Log.Info("Full processing (DuckDB)", logger.F("contentID", contentID)) + + contentInfo, err := queries.GetContentByID(context.Background(), contentID) + if err != nil { + return fmt.Errorf("failed to get content info: %w", err) + } + + ui.Log.Info("[1/6] Parsing DuckDB", logger.F("contentID", contentID)) + partyData, filterResult, err := party.ParseDuckDB(contentID, contentInfo.StartDate.Time) + if err != nil { + ui.Log.Warn("Skipping content", logger.F("contentID", contentID), logger.F("error", err)) + partyUpdated[contentID] = false + continue + } + partyUpdated[contentID] = true + + fileName := fmt.Sprintf("%s.json", contentID) + + ui.Log.Info("[2/6] Updating video references", logger.F("contentID", contentID)) + updated, err := party.UpdateVideoRefWithData(partyData, contentID) + if err != nil { + ui.Log.Warn("Failed to update video refs", logger.F("contentID", contentID), logger.F("error", err)) + } else { + ui.Log.Info("Updated video references", logger.F("count", updated), logger.F("contentID", contentID)) + } + + ui.Log.Info("[3/6] Uploading party data", logger.F("contentID", contentID)) + if err := storage.MarshalAndUpload(partyData, "batorment/v3/party", fileName, dryRun, ""); err != nil { + ui.Log.Warn("Failed to upload party data", logger.F("error", err)) + continue + } + + ui.Log.Info("[4/6] Creating and uploading video filter", logger.F("contentID", contentID)) + videoFilter := party.CreateVideoFilter(contentID) + if videoFilter != nil { + if err := storage.MarshalAndUpload(videoFilter, "batorment/v3/video-filter", fileName, dryRun, ""); err != nil { + ui.Log.Warn("Failed to upload video filter", logger.F("error", err)) + } + } else { + ui.Log.Warn("No video filter created", logger.F("contentID", contentID)) + } + + ui.Log.Info("[5/6] Uploading additional filters", logger.F("contentID", contentID)) + if err := storage.MarshalAndUpload(filterResult, "batorment/v3/filter", fileName, dryRun, ""); err != nil { + ui.Log.Warn("Failed to upload filter", logger.F("error", err)) + continue + } + + lunaticFilter := party.CreateLunaticFilter(partyData) + if err := storage.MarshalAndUpload(lunaticFilter, "batorment/v3/lunatic-filter", fileName, dryRun, ""); err != nil { + ui.Log.Warn("Failed to upload lunatic filter", logger.F("error", err)) + } + + nonLunaticFilter := party.CreateNonLunaticFilter(partyData) + if err := storage.MarshalAndUpload(nonLunaticFilter, "batorment/v3/nonlunatic-filter", fileName, dryRun, ""); err != nil { + ui.Log.Warn("Failed to upload non-lunatic filter", logger.F("error", err)) + } + + ui.Log.Info("[6/6] Processing and uploading summary data", logger.F("contentID", contentID)) + summaryData, err := party.ProcessPartyDataToSummaryData(partyData) + if err != nil { + ui.Log.Warn("Failed to process summary data", logger.F("error", err)) + continue + } + + platinumCuts, err := party.GetPlatinumCuts(contentID, contentInfo.StartDate.Time) + if err != nil { + ui.Log.Warn("Failed to get platinum cuts", logger.F("contentID", contentID), logger.F("error", err)) + } else { + summaryData.PlatinumCuts = platinumCuts + ui.Log.Info("Added platinum cuts", logger.F("count", len(platinumCuts)), logger.F("contentID", contentID)) + } + + if party.IsGrandAssault(contentID) { + partPlatinumCuts := party.GetPartPlatinumCutsFromPartyData(partyData) + if len(partPlatinumCuts) > 0 { + summaryData.PartPlatinumCuts = partPlatinumCuts + ui.Log.Info("Added part platinum cuts", logger.F("count", len(partPlatinumCuts)), logger.F("contentID", contentID)) + } + } + + essentialTorment, essentialLunatic := party.GetEssentialCharacters(partyData) + summaryData.Torment.EssentialCharacters = essentialTorment + summaryData.Lunatic.EssentialCharacters = essentialLunatic + ui.Log.Info("Essential characters", logger.F("contentID", contentID), logger.F("torment", len(essentialTorment)), logger.F("lunatic", len(essentialLunatic))) + + highImpactTorment, highImpactLunatic := party.GetHighImpactCharacters(partyData) + summaryData.Torment.HighImpactCharacters = highImpactTorment + summaryData.Lunatic.HighImpactCharacters = highImpactLunatic + ui.Log.Info("High impact characters", logger.F("contentID", contentID), logger.F("torment", len(highImpactTorment)), logger.F("lunatic", len(highImpactLunatic))) + + minUETorment, minUELunatic := party.GetMinUEUsers(partyData) + summaryData.Torment.MinUEUser = minUETorment + summaryData.Lunatic.MinUEUser = minUELunatic + if minUETorment != nil { + ui.Log.Info("Min UE user (Torment)", logger.F("contentID", contentID), logger.F("rank", minUETorment.Rank), logger.F("ueCount", minUETorment.UECount), logger.F("parties", len(minUETorment.PartyData))) + } + if minUELunatic != nil { + ui.Log.Info("Min UE user (Lunatic)", logger.F("contentID", contentID), logger.F("rank", minUELunatic.Rank), logger.F("ueCount", minUELunatic.UECount), logger.F("parties", len(minUELunatic.PartyData))) + } + + maxPartyTorment, maxPartyLunatic := party.GetMaxPartyUsers(partyData) + summaryData.Torment.MaxPartyUser = maxPartyTorment + summaryData.Lunatic.MaxPartyUser = maxPartyLunatic + if maxPartyTorment != nil { + ui.Log.Info("Max party user (Torment)", logger.F("contentID", contentID), logger.F("rank", maxPartyTorment.Rank), logger.F("parties", len(maxPartyTorment.PartyData))) + } + if maxPartyLunatic != nil { + ui.Log.Info("Max party user (Lunatic)", logger.F("contentID", contentID), logger.F("rank", maxPartyLunatic.Rank), logger.F("parties", len(maxPartyLunatic.PartyData))) + } + + if err := storage.MarshalAndUpload(summaryData, "batorment/v3/summary", fileName, dryRun, ""); err != nil { + ui.Log.Warn("Failed to upload summary data", logger.F("error", err)) + continue + } + + ui.Log.Info("Successfully processed content", logger.F("contentID", contentID)) + } + + ui.Log.Info("Generating raids.json") + var raidList []raidListItem + for _, content := range contents { + raidList = append(raidList, raidListItem{ + ID: content.ContentID, + Name: content.Title, + TopLevel: string(content.TopLevel), + PartyUpdated: partyUpdated[content.ContentID], + }) + } + + if err := storage.MarshalAndUpload(raidList, "batorment/v3", "raids.json", dryRun, "Raids list uploaded"); err != nil { + return fmt.Errorf("failed to upload raids.json: %w", err) + } + + ui.Log.Info("Successfully processed all raids") + return nil + }, +} + +func init() { + processRaidCmd.Flags().Bool("dry-run", false, "Run in dry-run mode (no actual uploads)") + processRaidCmd.Flags().Int("recent", 5, "Number of recent raids to fully process from DuckDB (older ones use cached S3 data)") + rootCmd.AddCommand(processRaidCmd) +} diff --git a/cmd/process_raid/main.go b/cmd/process_raid/main.go deleted file mode 100644 index a8cbc3c..0000000 --- a/cmd/process_raid/main.go +++ /dev/null @@ -1,204 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "log" - - "ba-torment-data-process/internal/db/postgres" - "ba-torment-data-process/internal/logic" - "ba-torment-data-process/internal/logic/party" - "ba-torment-data-process/internal/logic/storage" - - "github.com/joho/godotenv" -) - -// RaidListItem represents an item in raids.json -type RaidListItem struct { - ID string `json:"id"` - Name string `json:"name"` - TopLevel string `json:"top_level"` - PartyUpdated bool `json:"party_updated"` -} - -func main() { - if logic.IsLocalEnv() { - if err := godotenv.Load(); err != nil { - log.Fatalf("Failed to load .env file: %v", err) - } - } - - dryRun := flag.Bool("dry-run", false, "Run in dry-run mode (no actual uploads)") - flag.Parse() - - // Initialize database connection - pool := postgres.InitFromEnv() - defer pool.Close() - - queries := postgres.New(pool) - - // Get all contents for raid list - contents, err := queries.ListContentsForRaidList(context.Background()) - if err != nil { - log.Fatal(fmt.Errorf("failed to list contents: %w", err)) - } - - // Track party_updated status for each content - partyUpdated := make(map[string]bool) - - for _, content := range contents { - contentID := content.ContentID - log.Printf("\n=== Processing content: %s ===", contentID) - - contentInfo, err := queries.GetContentByID(context.Background(), contentID) - if err != nil { - log.Fatal(fmt.Errorf("failed to get content info: %w", err)) - } - - // Step 1: Parse DuckDB to create party data - log.Printf("[1/6] Parsing DuckDB for %s...", contentID) - partyData, filterResult, err := party.ParseDuckDB(contentID, contentInfo.StartDate.Time) - if err != nil { - log.Printf("Skipping content %s: %v", contentID, err) - partyUpdated[contentID] = false - continue - } - partyUpdated[contentID] = true - - fileName := fmt.Sprintf("%s.json", contentID) - - // Step 2: Update video references (without S3 download) - log.Printf("[2/6] Updating video references for %s...", contentID) - updated, err := party.UpdateVideoRefWithData(partyData, contentID) - if err != nil { - log.Printf("Warning: Failed to update video refs for %s: %v", contentID, err) - } else { - log.Printf("Updated %d video references for %s", updated, contentID) - } - - // Step 3: Upload party data (with video refs if updated) - log.Printf("[3/6] Uploading party data for %s...", contentID) - if err := storage.MarshalAndUpload(partyData, "batorment/v3/party", fileName, *dryRun, ""); err != nil { - log.Printf("Failed to upload party data: %v", err) - continue - } - - // Step 4: Create and upload video filter - log.Printf("[4/6] Creating and uploading video filter for %s...", contentID) - videoFilter := party.CreateVideoFilter(contentID) - if videoFilter != nil { - if err := storage.MarshalAndUpload(videoFilter, "batorment/v3/video-filter", fileName, *dryRun, ""); err != nil { - log.Printf("Warning: Failed to upload video filter: %v", err) - } - } else { - log.Printf("Warning: No video filter created for %s", contentID) - } - - // Step 5: Upload additional filters - log.Printf("[5/6] Uploading additional filters for %s...", contentID) - - // Upload basic filter - if err := storage.MarshalAndUpload(filterResult, "batorment/v3/filter", fileName, *dryRun, ""); err != nil { - log.Printf("Failed to upload filter: %v", err) - continue - } - - // Create and upload lunatic filter - lunaticFilter := party.CreateLunaticFilter(partyData) - if err := storage.MarshalAndUpload(lunaticFilter, "batorment/v3/lunatic-filter", fileName, *dryRun, ""); err != nil { - log.Printf("Failed to upload lunatic filter: %v", err) - } - - // Create and upload non-lunatic filter - nonLunaticFilter := party.CreateNonLunaticFilter(partyData) - if err := storage.MarshalAndUpload(nonLunaticFilter, "batorment/v3/nonlunatic-filter", fileName, *dryRun, ""); err != nil { - log.Printf("Failed to upload non-lunatic filter: %v", err) - } - - // Step 6: Create and upload summary data - log.Printf("[6/6] Processing and uploading summary data for %s...", contentID) - summaryData, err := party.ProcessPartyDataToSummaryData(partyData) - if err != nil { - log.Printf("Failed to process summary data: %v", err) - continue - } - - // Add platinum cuts to summary data - platinumCuts, err := party.GetPlatinumCuts(contentID, contentInfo.StartDate.Time) - if err != nil { - log.Printf("Warning: Failed to get platinum cuts for %s: %v", contentID, err) - } else { - summaryData.PlatinumCuts = platinumCuts - log.Printf("Added %d platinum cuts for %s", len(platinumCuts), contentID) - } - - // Add part platinum cuts for grand assault (individual part cuts from partyData) - if party.IsGrandAssault(contentID) { - partPlatinumCuts := party.GetPartPlatinumCutsFromPartyData(partyData) - if len(partPlatinumCuts) > 0 { - summaryData.PartPlatinumCuts = partPlatinumCuts - log.Printf("Added %d part platinum cuts for %s", len(partPlatinumCuts), contentID) - } - } - - // Add essential characters (70%+ usage in platinum ranks) - essentialTorment, essentialLunatic := party.GetEssentialCharacters(partyData) - summaryData.Torment.EssentialCharacters = essentialTorment - summaryData.Lunatic.EssentialCharacters = essentialLunatic - log.Printf("Essential characters for %s: Torment=%d, Lunatic=%d", contentID, len(essentialTorment), len(essentialLunatic)) - - // Add high impact characters (biggest score gap when missing) - highImpactTorment, highImpactLunatic := party.GetHighImpactCharacters(partyData) - summaryData.Torment.HighImpactCharacters = highImpactTorment - summaryData.Lunatic.HighImpactCharacters = highImpactLunatic - log.Printf("High impact characters for %s: Torment=%d, Lunatic=%d", contentID, len(highImpactTorment), len(highImpactLunatic)) - - // Add min UE users (users who cleared with minimum unique equipment) - minUETorment, minUELunatic := party.GetMinUEUsers(partyData) - summaryData.Torment.MinUEUser = minUETorment - summaryData.Lunatic.MinUEUser = minUELunatic - if minUETorment != nil { - log.Printf("Min UE user (Torment) for %s: rank %d, %d UE, %d parties", contentID, minUETorment.Rank, minUETorment.UECount, len(minUETorment.PartyData)) - } - if minUELunatic != nil { - log.Printf("Min UE user (Lunatic) for %s: rank %d, %d UE, %d parties", contentID, minUELunatic.Rank, minUELunatic.UECount, len(minUELunatic.PartyData)) - } - - // Add max party users (users who cleared with maximum party count) - maxPartyTorment, maxPartyLunatic := party.GetMaxPartyUsers(partyData) - summaryData.Torment.MaxPartyUser = maxPartyTorment - summaryData.Lunatic.MaxPartyUser = maxPartyLunatic - if maxPartyTorment != nil { - log.Printf("Max party user (Torment) for %s: rank %d, %d parties", contentID, maxPartyTorment.Rank, len(maxPartyTorment.PartyData)) - } - if maxPartyLunatic != nil { - log.Printf("Max party user (Lunatic) for %s: rank %d, %d parties", contentID, maxPartyLunatic.Rank, len(maxPartyLunatic.PartyData)) - } - - if err := storage.MarshalAndUpload(summaryData, "batorment/v3/summary", fileName, *dryRun, ""); err != nil { - log.Printf("Failed to upload summary data: %v", err) - continue - } - - log.Printf("Successfully processed content: %s\n", contentID) - } - - // Generate raids.json - log.Println("\n=== Generating raids.json ===") - var raidList []RaidListItem - for _, content := range contents { - raidList = append(raidList, RaidListItem{ - ID: content.ContentID, - Name: content.Title, - TopLevel: string(content.TopLevel), - PartyUpdated: partyUpdated[content.ContentID], - }) - } - - if err := storage.MarshalAndUpload(raidList, "batorment/v3", "raids.json", *dryRun, "Raids list uploaded"); err != nil { - log.Printf("Failed to upload raids.json: %v", err) - } - - fmt.Println("\n=== Successfully processed all raids ===") -} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..a35f40c --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/BeaverHouse/go-common/env" + "github.com/joho/godotenv" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "batorment", + Short: "Blue Archive torment data processing CLI", + Long: `CLI tool for processing and managing Blue Archive torment raid data (parties, analysis, grid images, etc.).`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if env.IsGoEnv(env.LocalEnv) { + if err := godotenv.Load(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to load .env file: %v\n", err) + os.Exit(1) + } + } + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/cmd/total_analysis.go b/cmd/total_analysis.go new file mode 100644 index 0000000..0a28092 --- /dev/null +++ b/cmd/total_analysis.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "context" + "fmt" + + "ba-torment-data-process/internal/db/postgres" + "ba-torment-data-process/internal/logic/analysis" + "ba-torment-data-process/internal/logic/storage" + "ba-torment-data-process/internal/ui" + + gopostgres "github.com/BeaverHouse/go-common/database/postgres" + "github.com/BeaverHouse/go-common/logger" + "github.com/spf13/cobra" +) + +var totalAnalysisCmd = &cobra.Command{ + Use: "total-analysis", + Short: "Run total analysis across all raid content", + RunE: func(cmd *cobra.Command, args []string) error { + dryRun, _ := cmd.Flags().GetBool("dry-run") + + pool := gopostgres.InitFromEnv() + defer pool.Close() + + queries := postgres.New(pool) + + contents, err := queries.ListContentIDsWithStartDate(context.Background()) + if err != nil { + return fmt.Errorf("failed to list content IDs: %w", err) + } + + contentIDs := make([]string, len(contents)) + for i, c := range contents { + contentIDs[i] = c.ContentID + } + + ui.Log.Info("Found content IDs", logger.F("count", len(contentIDs))) + + ui.Log.Info("Downloading party data from S3...") + partyDataMap := analysis.DownloadAllPartyData(contentIDs) + ui.Log.Info("Successfully downloaded party data", logger.F("downloaded", len(partyDataMap)), logger.F("total", len(contentIDs))) + + if len(partyDataMap) == 0 { + return fmt.Errorf("no party data available for analysis") + } + + ui.Log.Info("Running total analysis...") + result := analysis.RunTotalAnalysis(partyDataMap, contentIDs) + + if err := storage.MarshalAndUpload(result, "batorment/v3", "total-analysis.json", dryRun, "Total analysis completed"); err != nil { + return fmt.Errorf("failed to upload analysis result: %w", err) + } + + ui.Log.Info("Total analysis completed successfully!") + return nil + }, +} + +func init() { + totalAnalysisCmd.Flags().Bool("dry-run", false, "Run in dry-run mode (no actual uploads)") + rootCmd.AddCommand(totalAnalysisCmd) +} diff --git a/cmd/total_analysis/main.go b/cmd/total_analysis/main.go deleted file mode 100644 index b08218f..0000000 --- a/cmd/total_analysis/main.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "log" - - "ba-torment-data-process/internal/db/postgres" - "ba-torment-data-process/internal/logic" - "ba-torment-data-process/internal/logic/analysis" - "ba-torment-data-process/internal/logic/storage" - - "github.com/joho/godotenv" -) - -func main() { - if logic.IsLocalEnv() { - if err := godotenv.Load(); err != nil { - log.Fatalf("Failed to load .env file: %v", err) - } - } - - dryRun := flag.Bool("dry-run", false, "Run in dry-run mode (no actual uploads)") - flag.Parse() - - // Initialize database connection - pool := postgres.InitFromEnv() - defer pool.Close() - - queries := postgres.New(pool) - - // Get all content IDs sorted by start_date - contents, err := queries.ListContentIDsWithStartDate(context.Background()) - if err != nil { - log.Fatal(fmt.Errorf("failed to list content IDs: %w", err)) - } - - // Extract content IDs in order - contentIDs := make([]string, len(contents)) - for i, c := range contents { - contentIDs[i] = c.ContentID - } - - log.Printf("Found %d content IDs", len(contentIDs)) - - // Download all party data - log.Println("Downloading party data from S3...") - partyDataMap := analysis.DownloadAllPartyData(contentIDs) - log.Printf("Successfully downloaded %d/%d party data", len(partyDataMap), len(contentIDs)) - - if len(partyDataMap) == 0 { - log.Fatal("No party data available for analysis") - } - - // Run analysis - log.Println("Running total analysis...") - result := analysis.RunTotalAnalysis(partyDataMap, contentIDs) - - // Upload result - err = storage.MarshalAndUpload( - result, - "batorment/v3", - "total-analysis.json", - *dryRun, - "Total analysis completed", - ) - if err != nil { - log.Fatalf("Failed to upload analysis result: %v", err) - } - - log.Println("Total analysis completed successfully!") -} diff --git a/cmd/update_from_schaledb.go b/cmd/update_from_schaledb.go new file mode 100644 index 0000000..78fa3ec --- /dev/null +++ b/cmd/update_from_schaledb.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + + "ba-torment-data-process/internal/db/postgres" + "ba-torment-data-process/internal/logic/schaledb" + "ba-torment-data-process/internal/ui" + + gopostgres "github.com/BeaverHouse/go-common/database/postgres" + "github.com/spf13/cobra" +) + +var updateFromSchaleDBCmd = &cobra.Command{ + Use: "update-from-schaledb", + Short: "Update student and present data from SchaleDB", + RunE: func(cmd *cobra.Command, args []string) error { + pool := gopostgres.InitFromEnv() + defer pool.Close() + + queries := postgres.New(pool) + + if _, err := schaledb.ParseSchaleDBStudents(queries); err != nil { + return fmt.Errorf("failed to parse SchaleDB students: %w", err) + } + + if _, err := schaledb.ParseSchaleDBPresents(queries); err != nil { + return fmt.Errorf("failed to parse SchaleDB presents: %w", err) + } + + if err := schaledb.SaveI18nData(queries); err != nil { + return fmt.Errorf("failed to save i18n data: %w", err) + } + + ui.Log.Info("Successfully updated from SchaleDB") + return nil + }, +} + +func init() { + rootCmd.AddCommand(updateFromSchaleDBCmd) +} diff --git a/cmd/update_from_schaledb/main.go b/cmd/update_from_schaledb/main.go deleted file mode 100644 index 1b8cd03..0000000 --- a/cmd/update_from_schaledb/main.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "ba-torment-data-process/internal/db/postgres" - "ba-torment-data-process/internal/logic" - "ba-torment-data-process/internal/logic/schaledb" - "log" - - "github.com/joho/godotenv" -) - -func main() { - if logic.IsLocalEnv() { - if err := godotenv.Load(); err != nil { - log.Fatalf("Failed to load .env file: %v", err) - } - } - - // Initialize database connection - pool := postgres.InitFromEnv() - defer pool.Close() - - queries := postgres.New(pool) - _, err := schaledb.ParseSchaleDBStudents(queries) - if err != nil { - log.Fatalf("Failed to parse SchaleDB students: %v", err) - } - - _, err = schaledb.ParseSchaleDBPresents(queries) - if err != nil { - log.Fatalf("Failed to parse SchaleDB presents: %v", err) - } - - err = schaledb.SaveI18nData(queries) - if err != nil { - log.Fatalf("Failed to save i18n data: %v", err) - } - - log.Println("Successfully updated from SchaleDB") -} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index b92be4b..6cee691 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -9,7 +9,7 @@ echo "" # Step 1: Update SchaleDB data echo "[1/3] Updating student data from SchaleDB..." echo "--------------------------------------------" -/app/bin/update_from_schaledb +/app/bin/batorment update-from-schaledb if [ $? -eq 0 ]; then echo "✓ SchaleDB update completed successfully" else @@ -20,7 +20,7 @@ fi echo "" echo "[2/3] Processing raid data..." echo "--------------------------------------------" -/app/bin/process_raid +/app/bin/batorment process-raid if [ $? -eq 0 ]; then echo "✓ Raid processing completed successfully" else @@ -31,7 +31,7 @@ fi echo "" echo "[3/3] Running total analysis..." echo "--------------------------------------------" -/app/bin/total_analysis +/app/bin/batorment total-analysis if [ $? -eq 0 ]; then echo "✓ Total analysis completed successfully" else diff --git a/go.mod b/go.mod index 89d03b0..c0b578e 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,15 @@ module ba-torment-data-process -go 1.25.5 +go 1.26.1 require ( + github.com/BeaverHouse/go-common v1.0.4 github.com/andybalholm/brotli v1.2.0 github.com/fogleman/gg v1.3.0 github.com/jackc/pgx/v5 v5.7.6 github.com/joho/godotenv v1.5.1 github.com/marcboeker/go-duckdb v1.8.5 + github.com/spf13/cobra v1.10.2 golang.org/x/image v0.35.0 ) @@ -18,13 +20,17 @@ require ( github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/google/flatbuffers v25.9.23+incompatible // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/mod v0.31.0 // indirect diff --git a/go.sum b/go.sum index 75e5553..5f23f5c 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,12 @@ +github.com/BeaverHouse/go-common v1.0.4 h1:F4NGQlgbL6EtcgBvB7jsrRNz3f+pA5jGarjP2jWW4C8= +github.com/BeaverHouse/go-common v1.0.4/go.mod h1:ArTJoJ0PjFjAi/TiB34Tcno6rC4Go7MhrniAT4VEFKI= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4= github.com/apache/arrow-go/v18 v18.4.1/go.mod h1:tLyFubsAl17bvFdUAy24bsSvA/6ww95Iqi67fTpGu3E= github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -23,6 +26,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -50,17 +55,29 @@ github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFu github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= -github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= diff --git a/internal/db/postgres/postgres_init.go b/internal/db/postgres/postgres_init.go deleted file mode 100644 index 61b54bb..0000000 --- a/internal/db/postgres/postgres_init.go +++ /dev/null @@ -1,50 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "log" - - "ba-torment-data-process/internal/logic" - "ba-torment-data-process/internal/types" - - "github.com/jackc/pgx/v5/pgxpool" -) - -// Initializes a PostgreSQL connection from environment variables. -// Panics if connection fails. -func InitFromEnv() *pgxpool.Pool { - postgresConfig := types.PostgresConfig{ - Host: logic.GetEnv("POSTGRES_HOST", "localhost"), - Port: logic.GetIntEnv("POSTGRES_PORT", 5432), - User: logic.GetEnv("POSTGRES_USER", "postgres"), - Password: logic.GetEnv("POSTGRES_PASSWORD", "postgres"), - DBName: logic.GetEnv("POSTGRES_DB", "postgres"), - SSLMode: logic.GetEnv("POSTGRES_SSLMODE", "disable"), - } - - pool, err := NewPool(postgresConfig) - if err != nil { - log.Fatalf("Failed to connect to database: %v", err) - } - - return pool -} - -func NewPool(cfg types.PostgresConfig) (*pgxpool.Pool, error) { - dsn := fmt.Sprintf( - "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode, - ) - - pool, err := pgxpool.New(context.Background(), dsn) - if err != nil { - return nil, err - } - - if err := pool.Ping(context.Background()); err != nil { - return nil, err - } - - return pool, nil -} diff --git a/internal/logic/analysis/analysis.go b/internal/logic/analysis/analysis.go index 15f779f..29b4869 100644 --- a/internal/logic/analysis/analysis.go +++ b/internal/logic/analysis/analysis.go @@ -3,16 +3,37 @@ package analysis import ( "encoding/json" "io" - "log" "net/http" "sort" "time" "ba-torment-data-process/internal/types" + "ba-torment-data-process/internal/ui" + + "github.com/BeaverHouse/go-common/logger" ) const PartyDataBaseURL = "https://twauaebyyujvvvusbrwe.supabase.co/storage/v1/object/public/pb7h4uvn2b6m0lyu7i6r3j8ac/batorment/v3/party" +// DownloadPartyData downloads party data for a single content ID, returns nil on failure. +func DownloadPartyData(contentID string) *types.BATormentPartyData { + url := PartyDataBaseURL + "/" + contentID + ".json" + data, err := fetchPartyData(url) + if err != nil { + ui.Log.Warn("Failed to fetch party data", logger.F("contentID", contentID), logger.F("error", err)) + return nil + } + + var partyData types.BATormentPartyData + if err := json.Unmarshal(data, &partyData); err != nil { + ui.Log.Warn("Failed to parse party data", logger.F("contentID", contentID), logger.F("error", err)) + return nil + } + + ui.Log.Info("Downloaded party data", logger.F("contentID", contentID), logger.F("parties", len(partyData.PartyDetail))) + return &partyData +} + // DownloadAllPartyData downloads party data for all content IDs func DownloadAllPartyData(contentIDs []string) map[string]*types.BATormentPartyData { result := make(map[string]*types.BATormentPartyData) @@ -21,18 +42,18 @@ func DownloadAllPartyData(contentIDs []string) map[string]*types.BATormentPartyD url := PartyDataBaseURL + "/" + contentID + ".json" data, err := fetchPartyData(url) if err != nil { - log.Printf("Failed to fetch party data for %s: %v", contentID, err) + ui.Log.Warn("Failed to fetch party data", logger.F("contentID", contentID), logger.F("error", err)) continue } var partyData types.BATormentPartyData if err := json.Unmarshal(data, &partyData); err != nil { - log.Printf("Failed to parse party data for %s: %v", contentID, err) + ui.Log.Warn("Failed to parse party data", logger.F("contentID", contentID), logger.F("error", err)) continue } result[contentID] = &partyData - log.Printf("Downloaded party data for %s: %d parties", contentID, len(partyData.PartyDetail)) + ui.Log.Info("Downloaded party data", logger.F("contentID", contentID), logger.F("parties", len(partyData.PartyDetail))) } return result @@ -56,7 +77,7 @@ func fetchPartyData(url string) ([]byte, error) { return nil, err } - log.Printf("Fetched: url=%s, duration=%s", url, time.Since(start)) + ui.Log.Info("Fetched", logger.F("url", url), logger.F("duration", time.Since(start))) return body, nil } @@ -72,22 +93,22 @@ func (e *httpError) Error() string { // RunTotalAnalysis runs the complete analysis // sortedContentIDs provides the order for raidAnalyses (sorted by start_date) func RunTotalAnalysis(partyDataMap map[string]*types.BATormentPartyData, sortedContentIDs []string) *types.TotalAnalysisOutput { - log.Printf("Starting total analysis with %d raids", len(partyDataMap)) + ui.Log.Info("Starting total analysis", logger.F("raids", len(partyDataMap))) // Raid analysis - log.Println("Running raid analyses...") + ui.Log.Info("Running raid analyses...") raidAnalyses := RunRaidAnalyses(partyDataMap, sortedContentIDs) - log.Printf("Completed raid analyses: %d raids", len(raidAnalyses)) + ui.Log.Info("Completed raid analyses", logger.F("raids", len(raidAnalyses))) // Character analysis - log.Println("Running character analyses...") + ui.Log.Info("Running character analyses...") characterAnalyses := RunCharacterAnalyses(partyDataMap, sortedContentIDs) - log.Printf("Completed character analyses: %d characters", len(characterAnalyses)) + ui.Log.Info("Completed character analyses", logger.F("characters", len(characterAnalyses))) // Calculate and assign overall rankings - log.Println("Calculating overall rankings...") + ui.Log.Info("Calculating overall rankings...") AssignOverallRankings(characterAnalyses) - log.Println("Completed overall ranking calculation") + ui.Log.Info("Completed overall ranking calculation") return &types.TotalAnalysisOutput{ GeneratedAt: time.Now().Format(time.RFC3339), diff --git a/internal/logic/env.go b/internal/logic/env.go deleted file mode 100644 index 8d3c020..0000000 --- a/internal/logic/env.go +++ /dev/null @@ -1,43 +0,0 @@ -package logic - -import ( - "os" - "strconv" -) - -// Retrieves an environment variable with a default string value -func GetEnv(key, defaultValue string) string { - value := os.Getenv(key) - if value == "" { - return defaultValue - } - return value -} - -// Retrieves an environment variable with a default int value -func GetIntEnv(key string, defaultValue int) int { - valueStr := GetEnv(key, "") - if valueStr == "" { - return defaultValue - } - value, err := strconv.Atoi(valueStr) - if err != nil { - return defaultValue - } - return value -} - -// Check whether the current environment is local -func IsLocalEnv() bool { - return GetEnv("GO_ENV", "local") == "local" -} - -// Check whether the current environment is development -func IsDevelopmentEnv() bool { - return GetEnv("GO_ENV", "local") == "development" -} - -// Check whether the current environment is production -func IsProductionEnv() bool { - return GetEnv("GO_ENV", "local") == "production" -} diff --git a/internal/logic/gridimage/generate.go b/internal/logic/gridimage/generate.go index 9795850..812d5fe 100644 --- a/internal/logic/gridimage/generate.go +++ b/internal/logic/gridimage/generate.go @@ -6,14 +6,15 @@ import ( "fmt" "image" "image/jpeg" - "log" "sort" "strconv" "ba-torment-data-process/internal/logic/id" "ba-torment-data-process/internal/logic/storage" "ba-torment-data-process/internal/types" + "ba-torment-data-process/internal/ui" + "github.com/BeaverHouse/go-common/logger" "github.com/fogleman/gg" _ "golang.org/x/image/webp" ) @@ -53,7 +54,7 @@ func GenerateGridImages(dryRun bool) error { } totalCount := len(students) - log.Printf("Total students with usage data: %d", totalCount) + ui.Log.Info("Total students with usage data", logger.F("count", totalCount)) // Calculate percentile cutoffs tier1StartIdx := totalCount * tier1Start / 100 @@ -63,8 +64,10 @@ func GenerateGridImages(dryRun bool) error { tier3StartIdx := totalCount * tier3Start / 100 tier3EndIdx := totalCount * tier3End / 100 - log.Printf("Percentile cutoffs: Tier1=%d-%d, Tier2=%d-%d, Tier3=%d-%d", - tier1StartIdx, tier1EndIdx, tier2StartIdx, tier2EndIdx, tier3StartIdx, tier3EndIdx) + ui.Log.Info("Percentile cutoffs", + logger.F("tier1", fmt.Sprintf("%d-%d", tier1StartIdx, tier1EndIdx)), + logger.F("tier2", fmt.Sprintf("%d-%d", tier2StartIdx, tier2EndIdx)), + logger.F("tier3", fmt.Sprintf("%d-%d", tier3StartIdx, tier3EndIdx))) // Generate tier grids tiers := []struct { @@ -81,8 +84,11 @@ func GenerateGridImages(dryRun bool) error { tierStudents := students[tier.start:tier.end] strikers, specials := splitBySquadType(tierStudents) - log.Printf("Tier %s%%: Total=%d, Strikers=%d, Specials=%d", - tier.name, len(tierStudents), len(strikers), len(specials)) + ui.Log.Info("Tier stats", + logger.F("tier", tier.name+"%"), + logger.F("total", len(tierStudents)), + logger.F("strikers", len(strikers)), + logger.F("specials", len(specials))) strikerFile := fmt.Sprintf("grid_striker_%s.jpg", tier.name) specialFile := fmt.Sprintf("grid_special_%s.jpg", tier.name) @@ -95,7 +101,7 @@ func GenerateGridImages(dryRun bool) error { } } - log.Printf("Successfully generated 6 grids") + ui.Log.Info("Successfully generated 6 grids") return nil } @@ -139,7 +145,7 @@ func splitBySquadType(students []StudentInfo) ([]StudentInfo, []StudentInfo) { func generateAndUploadGrid(students []StudentInfo, fileName string, dryRun bool) error { if len(students) == 0 { - log.Printf("Skipping %s: no students", fileName) + ui.Log.Warn("Skipping grid", logger.F("file", fileName), logger.F("reason", "no students")) return nil } @@ -152,7 +158,7 @@ func generateAndUploadGrid(students []StudentInfo, fileName string, dryRun bool) return fmt.Errorf("failed to upload grid %s: %w", fileName, err) } - log.Printf("Generated: %s (%d students)", fileName, len(students)) + ui.Log.Info("Generated grid", logger.F("file", fileName), logger.F("students", len(students))) return nil } @@ -198,7 +204,7 @@ func generateGrid(students []StudentInfo) (image.Image, error) { // Draw portrait from Supabase portrait, err := fetchPortraitFromSupabase(student.ID) if err != nil { - log.Printf("Failed to fetch portrait for %d: %v", student.ID, err) + ui.Log.Warn("Failed to fetch portrait", logger.F("studentID", student.ID), logger.F("error", err)) continue } dc.DrawImage(portrait, int(x)+padding, int(y)+padding+fontSize+6) diff --git a/internal/logic/id/content.go b/internal/logic/id/content.go index 00d1c2e..9d53444 100644 --- a/internal/logic/id/content.go +++ b/internal/logic/id/content.go @@ -1,7 +1,7 @@ package id import ( - "log" + "fmt" "strconv" "strings" ) @@ -18,11 +18,11 @@ func ExtractGroupID(contentID string) string { func SplitSeasonString(season string) (string, int) { parts := strings.Split(season, "-") if len(parts) != 2 { - log.Fatalf("Invalid season string: %s", season) + panic(fmt.Sprintf("Invalid season string: %s", season)) } category, err := strconv.Atoi(parts[1]) if err != nil { - log.Fatalf("Invalid season string: %s", season) + panic(fmt.Sprintf("Invalid season string: %s", season)) } return strings.Replace(parts[0], "3S", "S", 1), category } diff --git a/internal/logic/party/filter.go b/internal/logic/party/filter.go index 680f470..cf21604 100644 --- a/internal/logic/party/filter.go +++ b/internal/logic/party/filter.go @@ -2,13 +2,16 @@ package party import ( "context" - "log" "strconv" "ba-torment-data-process/internal/constants" "ba-torment-data-process/internal/db/postgres" "ba-torment-data-process/internal/logic/id" "ba-torment-data-process/internal/types" + "ba-torment-data-process/internal/ui" + + gopostgres "github.com/BeaverHouse/go-common/database/postgres" + "github.com/BeaverHouse/go-common/logger" ) // === Filter update helpers === @@ -128,7 +131,7 @@ func createVideoFilterFromPartyTeams(partyTeams [][6]int) *types.BATormentFilter // CreateVideoFilter creates a video filter from verified YouTube analysis data in the database func CreateVideoFilter(raidID string) *types.BATormentFilter { - pool := postgres.InitFromEnv() + pool := gopostgres.InitFromEnv() defer pool.Close() ctx := context.Background() @@ -136,12 +139,12 @@ func CreateVideoFilter(raidID string) *types.BATormentFilter { analysisRows, err := queries.GetVerifiedYoutubeAnalysisByRaidID(ctx, raidID) if err != nil { - log.Printf("Failed to query YouTube analysis for %s: %v", raidID, err) + ui.Log.Warn("Failed to query YouTube analysis", logger.F("raidID", raidID), logger.F("error", err)) return nil } if len(analysisRows) == 0 { - log.Printf("No verified YouTube analysis found for %s", raidID) + ui.Log.Info("No verified YouTube analysis found", logger.F("raidID", raidID)) return nil } @@ -151,8 +154,7 @@ func CreateVideoFilter(raidID string) *types.BATormentFilter { partyTeams = append(partyTeams, row.AnalysisResult.PartyData...) } - log.Printf("Created video filter for %s with %d party teams from %d verified videos", - raidID, len(partyTeams), len(analysisRows)) + ui.Log.Info("Created video filter", logger.F("raidID", raidID), logger.F("partyTeams", len(partyTeams)), logger.F("videos", len(analysisRows))) return createVideoFilterFromPartyTeams(partyTeams) } diff --git a/internal/logic/party/parse.go b/internal/logic/party/parse.go index 0551274..a3c2b53 100644 --- a/internal/logic/party/parse.go +++ b/internal/logic/party/parse.go @@ -4,8 +4,6 @@ import ( "database/sql" "fmt" "io" - "log" - "math" "net/http" "os" "sort" @@ -15,14 +13,16 @@ import ( "ba-torment-data-process/internal/constants" "ba-torment-data-process/internal/logic/id" "ba-torment-data-process/internal/types" + "ba-torment-data-process/internal/ui" + "github.com/BeaverHouse/go-common/env" + "github.com/BeaverHouse/go-common/logger" "github.com/andybalholm/brotli" _ "github.com/marcboeker/go-duckdb" ) -// downloadDuckDB downloads the DuckDB file from CloudFront func downloadDuckDB(dateString string) error { - baseURL := os.Getenv("BATORMENT_DUCKDB_REMOTE_URL") + baseURL := env.GetEnv("BATORMENT_DUCKDB_REMOTE_URL", "") if baseURL == "" { return fmt.Errorf("BATORMENT_DUCKDB_REMOTE_URL environment variable is not set") } @@ -30,7 +30,7 @@ func downloadDuckDB(dateString string) error { url := fmt.Sprintf("%s/v1/JP/%s.db", baseURL, dateString) fileName := fmt.Sprintf("%s.db", dateString) - log.Printf("Downloading DuckDB from: %s", url) + ui.Log.Info("Downloading DuckDB", logger.F("url", url)) client := &http.Client{ Timeout: 5 * time.Minute, @@ -55,8 +55,10 @@ func downloadDuckDB(dateString string) error { } contentEncoding := resp.Header.Get("Content-Encoding") - log.Printf("Response Content-Type: %s, Content-Length: %d, Content-Encoding: %s", - resp.Header.Get("Content-Type"), resp.ContentLength, contentEncoding) + ui.Log.Info("Response metadata", + logger.F("contentType", resp.Header.Get("Content-Type")), + logger.F("contentLength", resp.ContentLength), + logger.F("contentEncoding", contentEncoding)) out, err := os.Create(fileName) if err != nil { @@ -66,7 +68,7 @@ func downloadDuckDB(dateString string) error { var reader io.Reader = resp.Body if contentEncoding == "br" { - log.Printf("Decompressing Brotli-encoded file...") + ui.Log.Info("Decompressing Brotli-encoded file...") reader = brotli.NewReader(resp.Body) } @@ -76,7 +78,7 @@ func downloadDuckDB(dateString string) error { return fmt.Errorf("failed to write file %s: %w", fileName, err) } - log.Printf("Downloaded and wrote %d bytes (%.2f MB) to %s", written, float64(written)/(1024*1024), fileName) + ui.Log.Info("Downloaded file", logger.F("bytes", written), logger.F("mb", fmt.Sprintf("%.2f", float64(written)/(1024*1024))), logger.F("file", fileName)) if written < 10240 { os.Remove(fileName) @@ -91,12 +93,12 @@ func ParseDuckDB(contentID string, startDate time.Time) (*types.BATormentPartyDa dbFileName := fmt.Sprintf("%s.db", dateString) if _, err := os.Stat(dbFileName); os.IsNotExist(err) { - log.Printf("DuckDB file %s not found, attempting to download...", dbFileName) + ui.Log.Info("DuckDB file not found, attempting to download", logger.F("file", dbFileName)) if err := downloadDuckDB(dateString); err != nil { - log.Printf("Info: Failed to download DuckDB file %s: %v. Skipping this raid.", dbFileName, err) + ui.Log.Info("Failed to download DuckDB file, skipping this raid", logger.F("file", dbFileName), logger.F("error", err)) return nil, nil, fmt.Errorf("duckdb file not available: %w", err) } - log.Printf("Successfully downloaded DuckDB file: %s", dbFileName) + ui.Log.Info("Successfully downloaded DuckDB file", logger.F("file", dbFileName)) } db, err := sql.Open("duckdb", dbFileName) @@ -123,7 +125,6 @@ func ParseDuckDB(contentID string, startDate time.Time) (*types.BATormentPartyDa return partyData, filterResult, nil } -// removeFraudUsers removes known fraudulent user data and adjusts ranks func removeFraudUsers(contentID string, partyData *types.BATormentPartyData) { if contentID != "S80-0" { return @@ -146,7 +147,7 @@ func removeFraudUsers(contentID string, partyData *types.BATormentPartyData) { } if hasFraudChar { fraudIndex = i - log.Printf("Found fraud user at rank 13622 with Mika star1 UE3 in S80-0") + ui.Log.Warn("Found fraud user at rank 13622 with Mika star1 UE3 in S80-0") } break } @@ -162,7 +163,7 @@ func removeFraudUsers(contentID string, partyData *types.BATormentPartyData) { partyData.PartyDetail[i].Rank-- } - log.Printf("Removed fraud user and adjusted %d ranks", len(partyData.PartyDetail)-fraudIndex) + ui.Log.Info("Removed fraud user and adjusted ranks", logger.F("adjustedRanks", len(partyData.PartyDetail)-fraudIndex)) } func processArmorType(db *sql.DB, armorType string) (*types.BATormentPartyData, *types.BATormentFilter, error) { @@ -316,379 +317,18 @@ func getPartyByRunID(db *sql.DB, armorType string, runID int) ([6]int, error) { return partyMembers, nil } -// IsGrandAssault checks if the contentID represents a grand assault (대결전) +// IsGrandAssault checks if the contentID represents a grand assault func IsGrandAssault(contentID string) bool { return strings.HasPrefix(contentID, "3S") } -func getExistingPointColumns(db *sql.DB) ([]string, error) { - rows, err := db.Query(getCompleteRunsColumnsSQL()) - if err != nil { - return nil, err - } - defer rows.Close() - - armorTypes := []string{"Light_point", "Heavy_point", "Special_point", "Elastic_point"} - armorTypeSet := make(map[string]bool) - for _, at := range armorTypes { - armorTypeSet[at] = true - } - - var existingColumns []string - for rows.Next() { - var columnName string - if err := rows.Scan(&columnName); err != nil { - return nil, err - } - if armorTypeSet[columnName] { - existingColumns = append(existingColumns, columnName) - } - } - - return existingColumns, nil -} - -// GetEssentialCharacters returns characters used by 70%+ of users (excluding assists) -func GetEssentialCharacters(partyData *types.BATormentPartyData) (torment []types.EssentialCharacter, lunatic []types.EssentialCharacter) { - if len(partyData.PartyDetail) == 0 { - return nil, nil - } - - isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore - - tormentCharCount := make(map[int]int) - lunaticCharCount := make(map[int]int) - var tormentUsers, lunaticUsers int - - for _, party := range partyData.PartyDetail { - if party.Rank > constants.PlatinumRankLimit { - break - } - - isLunatic := party.Score >= constants.LunaticMinScore - isTorment := isInsane || party.Score >= constants.TormentMinScore - - if isLunatic { - lunaticUsers++ - } else if isTorment { - tormentUsers++ - } else { - continue - } - - for _, members := range party.PartyData { - for _, member := range members { - if member == 0 { - continue - } - if member%10 == 1 { - continue - } - studentID := id.GetStudentID(member) - if isLunatic { - lunaticCharCount[studentID]++ - } else { - tormentCharCount[studentID]++ - } - } - } - } - - calcEssential := func(charCount map[int]int, totalUsers int) []types.EssentialCharacter { - if totalUsers == 0 { - return nil - } - - threshold := float64(totalUsers) * 0.7 - type charUsage struct { - studentID int - count int - } - var usages []charUsage - for sid, count := range charCount { - if float64(count) >= threshold { - usages = append(usages, charUsage{sid, count}) - } - } - - sort.Slice(usages, func(i, j int) bool { - return usages[i].count > usages[j].count - }) - - var result []types.EssentialCharacter - for _, u := range usages { - ratio := float64(u.count) / float64(totalUsers) - result = append(result, types.EssentialCharacter{ - StudentID: u.studentID, - Ratio: math.Round(ratio*1000) / 1000, - }) - } - return result - } - - torment = calcEssential(tormentCharCount, tormentUsers) - lunatic = calcEssential(lunaticCharCount, lunaticUsers) - - return torment, lunatic -} - -// GetMinUEUsers returns users who cleared with minimum unique equipment usage -func GetMinUEUsers(partyData *types.BATormentPartyData) (torment *types.MinUEUser, lunatic *types.MinUEUser) { - if len(partyData.PartyDetail) == 0 { - return nil, nil - } - - isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore - - type userUEData struct { - rank int - score int - ueCount int - partyCount int - partyData [][6]int - } - - var tormentUsers, lunaticUsers []userUEData - - for _, party := range partyData.PartyDetail { - if party.Rank > constants.PlatinumRankLimit { - break - } - - ueCount := 0 - for _, members := range party.PartyData { - for _, member := range members { - if member == 0 { - continue - } - if member%10 == 1 { - continue - } - weaponStar := (member % 100) / 10 - if weaponStar > 0 { - ueCount++ - } - } - } - - userData := userUEData{ - rank: party.Rank, - score: party.Score, - ueCount: ueCount, - partyCount: len(party.PartyData), - partyData: party.PartyData, - } - - if party.Score >= constants.LunaticMinScore { - lunaticUsers = append(lunaticUsers, userData) - } else if isInsane || party.Score >= constants.TormentMinScore { - tormentUsers = append(tormentUsers, userData) - } - } - - sortFunc := func(users []userUEData) { - sort.Slice(users, func(i, j int) bool { - if users[i].ueCount != users[j].ueCount { - return users[i].ueCount < users[j].ueCount - } - if users[i].partyCount != users[j].partyCount { - return users[i].partyCount < users[j].partyCount - } - return users[i].rank < users[j].rank - }) - } - - if len(tormentUsers) > 0 { - sortFunc(tormentUsers) - torment = &types.MinUEUser{ - Rank: tormentUsers[0].rank, - Score: tormentUsers[0].score, - UECount: tormentUsers[0].ueCount, - PartyData: tormentUsers[0].partyData, - } - } - - if len(lunaticUsers) > 0 { - sortFunc(lunaticUsers) - lunatic = &types.MinUEUser{ - Rank: lunaticUsers[0].rank, - Score: lunaticUsers[0].score, - UECount: lunaticUsers[0].ueCount, - PartyData: lunaticUsers[0].partyData, - } - } - - return torment, lunatic -} - -// GetMaxPartyUsers returns users who cleared with maximum party count -func GetMaxPartyUsers(partyData *types.BATormentPartyData) (torment *types.MaxPartyUser, lunatic *types.MaxPartyUser) { - if len(partyData.PartyDetail) == 0 { - return nil, nil - } - - isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore - - var tormentMaxCount, lunaticMaxCount int - - for _, party := range partyData.PartyDetail { - if party.Rank > constants.PlatinumRankLimit { - break - } - - partyCount := len(party.PartyData) - - if party.Score >= constants.LunaticMinScore { - if partyCount > lunaticMaxCount { - lunaticMaxCount = partyCount - lunatic = &types.MaxPartyUser{ - Rank: party.Rank, - Score: party.Score, - PartyData: party.PartyData, - } - } - } else if isInsane || party.Score >= constants.TormentMinScore { - if partyCount > tormentMaxCount { - tormentMaxCount = partyCount - torment = &types.MaxPartyUser{ - Rank: party.Rank, - Score: party.Score, - PartyData: party.PartyData, - } - } - } - } - - return torment, lunatic -} - -// GetHighImpactCharacters returns top 3 characters with the biggest rank gap when missing -func GetHighImpactCharacters(partyData *types.BATormentPartyData) (torment []types.HighImpactCharacter, lunatic []types.HighImpactCharacter) { - if len(partyData.PartyDetail) == 0 { - return nil, nil - } - - isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore - - type partyInfo struct { - rank int - usedChars map[int]bool - } - - var tormentParties, lunaticParties []partyInfo - - for _, party := range partyData.PartyDetail { - if party.Rank > constants.PlatinumRankLimit { - break - } - - usedChars := make(map[int]bool) - for _, members := range party.PartyData { - for _, member := range members { - if member == 0 { - continue - } - usedChars[id.GetStudentID(member)] = true - } - } - - info := partyInfo{rank: party.Rank, usedChars: usedChars} - - if party.Score >= constants.LunaticMinScore { - lunaticParties = append(lunaticParties, info) - } else if isInsane || party.Score >= constants.TormentMinScore { - tormentParties = append(tormentParties, info) - } - } - - findBestRankWithout := func(parties []partyInfo, charID int) int { - bestRank := 0 - for _, p := range parties { - if !p.usedChars[charID] { - if bestRank == 0 || p.rank < bestRank { - bestRank = p.rank - } - } - } - return bestRank - } - - calcHighImpact := func(parties []partyInfo, fallbackParties []partyInfo, top100Limit int) []types.HighImpactCharacter { - if len(parties) == 0 { - return nil - } - - topRank := parties[0].rank - - top100Chars := make(map[int]bool) - for i, p := range parties { - if i >= top100Limit { - break - } - for charID := range p.usedChars { - top100Chars[charID] = true - } - } - - type charGap struct { - studentID int - rankGap int - withoutBestRank int - } - var gaps []charGap - - for charID := range top100Chars { - withoutBestRank := findBestRankWithout(parties, charID) - - if withoutBestRank == 0 && len(fallbackParties) > 0 { - withoutBestRank = findBestRankWithout(fallbackParties, charID) - } - - var rankGap int - if withoutBestRank > 0 { - rankGap = withoutBestRank - topRank - } else { - rankGap = -1 - } - - gaps = append(gaps, charGap{charID, rankGap, withoutBestRank}) - } - - sort.Slice(gaps, func(i, j int) bool { - if gaps[i].rankGap == -1 && gaps[j].rankGap != -1 { - return true - } - if gaps[i].rankGap != -1 && gaps[j].rankGap == -1 { - return false - } - return gaps[i].rankGap > gaps[j].rankGap - }) - - var result []types.HighImpactCharacter - for i := 0; i < 3 && i < len(gaps); i++ { - result = append(result, types.HighImpactCharacter{ - StudentID: gaps[i].studentID, - RankGap: gaps[i].rankGap, - TopRank: topRank, - WithoutBestRank: gaps[i].withoutBestRank, - }) - } - return result - } - - torment = calcHighImpact(tormentParties, nil, 100) - lunatic = calcHighImpact(lunaticParties, tormentParties, 100) - - return torment, lunatic -} - // GetPlatinumCuts retrieves score cutoffs at specific ranks func GetPlatinumCuts(contentID string, startDate time.Time) ([]types.PlatinumCut, error) { dateString := startDate.Format("20060102") dbFileName := fmt.Sprintf("%s.db", dateString) if _, err := os.Stat(dbFileName); os.IsNotExist(err) { - log.Printf("DuckDB file %s not found for platinum cuts", dbFileName) + ui.Log.Warn("DuckDB file not found for platinum cuts", logger.F("file", dbFileName)) return nil, fmt.Errorf("duckdb file not available: %w", err) } @@ -762,96 +402,3 @@ func GetPartPlatinumCutsFromPartyData(partyData *types.BATormentPartyData) []typ return cuts } - -// === SQL helpers === - -func getCompleteRunIDAndScoreSQL(armorType string) string { - columnName := "point" - if armorType != "" { - columnName = armorType + "_point" - } - return fmt.Sprintf(` -SELECT crunid, cr.%s -FROM complete_runs cr -ORDER BY cr.%s DESC -`, columnName, columnName) -} - -func getRunIDsByCompleteRunIDSQL(armorType string, completeRunID int) string { - tableName := "runs" - if armorType != "" { - tableName = "runs_" + armorType - } - return fmt.Sprintf(` -SELECT r.runid -FROM %s r -WHERE r.crunid = %d -`, tableName, completeRunID) -} - -func getPartyInfoByRunIDSQL(armorType string, runID int) string { - tableName := "students" - if armorType != "" { - tableName = "students_" + armorType - } - return fmt.Sprintf(` -SELECT sid, build, level, slot, assist -FROM %s -WHERE runid = %d -`, tableName, runID) -} - -func getPlatinumCutSQL(ranks []int) string { - return fmt.Sprintf(` -WITH ranked AS ( - SELECT point, ROW_NUMBER() OVER (ORDER BY point DESC) as rank - FROM complete_runs - WHERE point > 0 -) -SELECT rank, point -FROM ranked -WHERE rank IN (%s) -ORDER BY rank -`, intSliceToSQL(ranks)) -} - -func getPartialPlatinumCutSQL(ranks []int, existingColumns []string) string { - if len(existingColumns) == 0 { - return "" - } - - sumParts := make([]string, len(existingColumns)) - for i, col := range existingColumns { - sumParts[i] = fmt.Sprintf("COALESCE(%s, 0)", col) - } - sumExpr := strings.Join(sumParts, " + ") - - return fmt.Sprintf(` -WITH ranked AS ( - SELECT - %s as total_point, - ROW_NUMBER() OVER (ORDER BY %s DESC) as rank - FROM complete_runs - WHERE %s > 0 -) -SELECT rank, total_point -FROM ranked -WHERE rank IN (%s) -ORDER BY rank -`, sumExpr, sumExpr, sumExpr, intSliceToSQL(ranks)) -} - -func getCompleteRunsColumnsSQL() string { - return `SELECT column_name FROM information_schema.columns WHERE table_name = 'complete_runs'` -} - -func intSliceToSQL(nums []int) string { - if len(nums) == 0 { - return "0" - } - result := fmt.Sprintf("%d", nums[0]) - for i := 1; i < len(nums); i++ { - result += fmt.Sprintf(", %d", nums[i]) - } - return result -} diff --git a/internal/logic/party/sql.go b/internal/logic/party/sql.go new file mode 100644 index 0000000..9de688f --- /dev/null +++ b/internal/logic/party/sql.go @@ -0,0 +1,125 @@ +package party + +import ( + "database/sql" + "fmt" + "strings" +) + +func getCompleteRunIDAndScoreSQL(armorType string) string { + columnName := "point" + if armorType != "" { + columnName = armorType + "_point" + } + return fmt.Sprintf(` +SELECT crunid, cr.%s +FROM complete_runs cr +ORDER BY cr.%s DESC +`, columnName, columnName) +} + +func getRunIDsByCompleteRunIDSQL(armorType string, completeRunID int) string { + tableName := "runs" + if armorType != "" { + tableName = "runs_" + armorType + } + return fmt.Sprintf(` +SELECT r.runid +FROM %s r +WHERE r.crunid = %d +`, tableName, completeRunID) +} + +func getPartyInfoByRunIDSQL(armorType string, runID int) string { + tableName := "students" + if armorType != "" { + tableName = "students_" + armorType + } + return fmt.Sprintf(` +SELECT sid, build, level, slot, assist +FROM %s +WHERE runid = %d +`, tableName, runID) +} + +func getPlatinumCutSQL(ranks []int) string { + return fmt.Sprintf(` +WITH ranked AS ( + SELECT point, ROW_NUMBER() OVER (ORDER BY point DESC) as rank + FROM complete_runs + WHERE point > 0 +) +SELECT rank, point +FROM ranked +WHERE rank IN (%s) +ORDER BY rank +`, intSliceToSQL(ranks)) +} + +func getPartialPlatinumCutSQL(ranks []int, existingColumns []string) string { + if len(existingColumns) == 0 { + return "" + } + + sumParts := make([]string, len(existingColumns)) + for i, col := range existingColumns { + sumParts[i] = fmt.Sprintf("COALESCE(%s, 0)", col) + } + sumExpr := strings.Join(sumParts, " + ") + + return fmt.Sprintf(` +WITH ranked AS ( + SELECT + %s as total_point, + ROW_NUMBER() OVER (ORDER BY %s DESC) as rank + FROM complete_runs + WHERE %s > 0 +) +SELECT rank, total_point +FROM ranked +WHERE rank IN (%s) +ORDER BY rank +`, sumExpr, sumExpr, sumExpr, intSliceToSQL(ranks)) +} + +func getCompleteRunsColumnsSQL() string { + return `SELECT column_name FROM information_schema.columns WHERE table_name = 'complete_runs'` +} + +func intSliceToSQL(nums []int) string { + if len(nums) == 0 { + return "0" + } + result := fmt.Sprintf("%d", nums[0]) + for i := 1; i < len(nums); i++ { + result += fmt.Sprintf(", %d", nums[i]) + } + return result +} + +func getExistingPointColumns(db *sql.DB) ([]string, error) { + rows, err := db.Query(getCompleteRunsColumnsSQL()) + if err != nil { + return nil, err + } + defer rows.Close() + + armorTypes := []string{"Light_point", "Heavy_point", "Special_point", "Elastic_point"} + armorTypeSet := make(map[string]bool) + for _, at := range armorTypes { + armorTypeSet[at] = true + } + + var existingColumns []string + for rows.Next() { + var columnName string + if err := rows.Scan(&columnName); err != nil { + return nil, err + } + if armorTypeSet[columnName] { + existingColumns = append(existingColumns, columnName) + } + } + + return existingColumns, nil +} diff --git a/internal/logic/party/stats.go b/internal/logic/party/stats.go new file mode 100644 index 0000000..9ea0859 --- /dev/null +++ b/internal/logic/party/stats.go @@ -0,0 +1,344 @@ +package party + +import ( + "math" + "sort" + + "ba-torment-data-process/internal/constants" + "ba-torment-data-process/internal/logic/id" + "ba-torment-data-process/internal/types" +) + +// GetEssentialCharacters returns characters used by 70%+ of users (excluding assists) +func GetEssentialCharacters(partyData *types.BATormentPartyData) (torment []types.EssentialCharacter, lunatic []types.EssentialCharacter) { + if len(partyData.PartyDetail) == 0 { + return nil, nil + } + + isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore + + tormentCharCount := make(map[int]int) + lunaticCharCount := make(map[int]int) + var tormentUsers, lunaticUsers int + + for _, party := range partyData.PartyDetail { + if party.Rank > constants.PlatinumRankLimit { + break + } + + isLunatic := party.Score >= constants.LunaticMinScore + isTorment := isInsane || party.Score >= constants.TormentMinScore + + if isLunatic { + lunaticUsers++ + } else if isTorment { + tormentUsers++ + } else { + continue + } + + for _, members := range party.PartyData { + for _, member := range members { + if member == 0 { + continue + } + if member%10 == 1 { + continue + } + studentID := id.GetStudentID(member) + if isLunatic { + lunaticCharCount[studentID]++ + } else { + tormentCharCount[studentID]++ + } + } + } + } + + calcEssential := func(charCount map[int]int, totalUsers int) []types.EssentialCharacter { + if totalUsers == 0 { + return nil + } + + threshold := float64(totalUsers) * 0.7 + type charUsage struct { + studentID int + count int + } + var usages []charUsage + for sid, count := range charCount { + if float64(count) >= threshold { + usages = append(usages, charUsage{sid, count}) + } + } + + sort.Slice(usages, func(i, j int) bool { + return usages[i].count > usages[j].count + }) + + var result []types.EssentialCharacter + for _, u := range usages { + ratio := float64(u.count) / float64(totalUsers) + result = append(result, types.EssentialCharacter{ + StudentID: u.studentID, + Ratio: math.Round(ratio*1000) / 1000, + }) + } + return result + } + + torment = calcEssential(tormentCharCount, tormentUsers) + lunatic = calcEssential(lunaticCharCount, lunaticUsers) + + return torment, lunatic +} + +// GetMinUEUsers returns users who cleared with minimum unique equipment usage +func GetMinUEUsers(partyData *types.BATormentPartyData) (torment *types.MinUEUser, lunatic *types.MinUEUser) { + if len(partyData.PartyDetail) == 0 { + return nil, nil + } + + isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore + + type userUEData struct { + rank int + score int + ueCount int + partyCount int + partyData [][6]int + } + + var tormentUsers, lunaticUsers []userUEData + + for _, party := range partyData.PartyDetail { + if party.Rank > constants.PlatinumRankLimit { + break + } + + ueCount := 0 + for _, members := range party.PartyData { + for _, member := range members { + if member == 0 { + continue + } + if member%10 == 1 { + continue + } + weaponStar := (member % 100) / 10 + if weaponStar > 0 { + ueCount++ + } + } + } + + userData := userUEData{ + rank: party.Rank, + score: party.Score, + ueCount: ueCount, + partyCount: len(party.PartyData), + partyData: party.PartyData, + } + + if party.Score >= constants.LunaticMinScore { + lunaticUsers = append(lunaticUsers, userData) + } else if isInsane || party.Score >= constants.TormentMinScore { + tormentUsers = append(tormentUsers, userData) + } + } + + sortFunc := func(users []userUEData) { + sort.Slice(users, func(i, j int) bool { + if users[i].ueCount != users[j].ueCount { + return users[i].ueCount < users[j].ueCount + } + if users[i].partyCount != users[j].partyCount { + return users[i].partyCount < users[j].partyCount + } + return users[i].rank < users[j].rank + }) + } + + if len(tormentUsers) > 0 { + sortFunc(tormentUsers) + torment = &types.MinUEUser{ + Rank: tormentUsers[0].rank, + Score: tormentUsers[0].score, + UECount: tormentUsers[0].ueCount, + PartyData: tormentUsers[0].partyData, + } + } + + if len(lunaticUsers) > 0 { + sortFunc(lunaticUsers) + lunatic = &types.MinUEUser{ + Rank: lunaticUsers[0].rank, + Score: lunaticUsers[0].score, + UECount: lunaticUsers[0].ueCount, + PartyData: lunaticUsers[0].partyData, + } + } + + return torment, lunatic +} + +// GetMaxPartyUsers returns users who cleared with maximum party count +func GetMaxPartyUsers(partyData *types.BATormentPartyData) (torment *types.MaxPartyUser, lunatic *types.MaxPartyUser) { + if len(partyData.PartyDetail) == 0 { + return nil, nil + } + + isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore + + var tormentMaxCount, lunaticMaxCount int + + for _, party := range partyData.PartyDetail { + if party.Rank > constants.PlatinumRankLimit { + break + } + + partyCount := len(party.PartyData) + + if party.Score >= constants.LunaticMinScore { + if partyCount > lunaticMaxCount { + lunaticMaxCount = partyCount + lunatic = &types.MaxPartyUser{ + Rank: party.Rank, + Score: party.Score, + PartyData: party.PartyData, + } + } + } else if isInsane || party.Score >= constants.TormentMinScore { + if partyCount > tormentMaxCount { + tormentMaxCount = partyCount + torment = &types.MaxPartyUser{ + Rank: party.Rank, + Score: party.Score, + PartyData: party.PartyData, + } + } + } + } + + return torment, lunatic +} + +// GetHighImpactCharacters returns top 3 characters with the biggest rank gap when missing +func GetHighImpactCharacters(partyData *types.BATormentPartyData) (torment []types.HighImpactCharacter, lunatic []types.HighImpactCharacter) { + if len(partyData.PartyDetail) == 0 { + return nil, nil + } + + isInsane := partyData.PartyDetail[0].Score < constants.TormentMinScore + + type partyInfo struct { + rank int + usedChars map[int]bool + } + + var tormentParties, lunaticParties []partyInfo + + for _, party := range partyData.PartyDetail { + if party.Rank > constants.PlatinumRankLimit { + break + } + + usedChars := make(map[int]bool) + for _, members := range party.PartyData { + for _, member := range members { + if member == 0 { + continue + } + usedChars[id.GetStudentID(member)] = true + } + } + + info := partyInfo{rank: party.Rank, usedChars: usedChars} + + if party.Score >= constants.LunaticMinScore { + lunaticParties = append(lunaticParties, info) + } else if isInsane || party.Score >= constants.TormentMinScore { + tormentParties = append(tormentParties, info) + } + } + + findBestRankWithout := func(parties []partyInfo, charID int) int { + bestRank := 0 + for _, p := range parties { + if !p.usedChars[charID] { + if bestRank == 0 || p.rank < bestRank { + bestRank = p.rank + } + } + } + return bestRank + } + + calcHighImpact := func(parties []partyInfo, fallbackParties []partyInfo, top100Limit int) []types.HighImpactCharacter { + if len(parties) == 0 { + return nil + } + + topRank := parties[0].rank + + top100Chars := make(map[int]bool) + for i, p := range parties { + if i >= top100Limit { + break + } + for charID := range p.usedChars { + top100Chars[charID] = true + } + } + + type charGap struct { + studentID int + rankGap int + withoutBestRank int + } + var gaps []charGap + + for charID := range top100Chars { + withoutBestRank := findBestRankWithout(parties, charID) + + if withoutBestRank == 0 && len(fallbackParties) > 0 { + withoutBestRank = findBestRankWithout(fallbackParties, charID) + } + + var rankGap int + if withoutBestRank > 0 { + rankGap = withoutBestRank - topRank + } else { + rankGap = -1 + } + + gaps = append(gaps, charGap{charID, rankGap, withoutBestRank}) + } + + sort.Slice(gaps, func(i, j int) bool { + if gaps[i].rankGap == -1 && gaps[j].rankGap != -1 { + return true + } + if gaps[i].rankGap != -1 && gaps[j].rankGap == -1 { + return false + } + return gaps[i].rankGap > gaps[j].rankGap + }) + + var result []types.HighImpactCharacter + for i := 0; i < 3 && i < len(gaps); i++ { + result = append(result, types.HighImpactCharacter{ + StudentID: gaps[i].studentID, + RankGap: gaps[i].rankGap, + TopRank: topRank, + WithoutBestRank: gaps[i].withoutBestRank, + }) + } + return result + } + + torment = calcHighImpact(tormentParties, nil, 100) + lunatic = calcHighImpact(lunaticParties, tormentParties, 100) + + return torment, lunatic +} diff --git a/internal/logic/party/video.go b/internal/logic/party/video.go index e469aeb..dcdc80a 100644 --- a/internal/logic/party/video.go +++ b/internal/logic/party/video.go @@ -3,11 +3,14 @@ package party import ( "context" "fmt" - "log" "ba-torment-data-process/internal/db/postgres" "ba-torment-data-process/internal/logic/id" "ba-torment-data-process/internal/types" + "ba-torment-data-process/internal/ui" + + gopostgres "github.com/BeaverHouse/go-common/database/postgres" + "github.com/BeaverHouse/go-common/logger" ) const ( @@ -17,7 +20,7 @@ const ( // UpdateVideoRefWithData updates video references for party data func UpdateVideoRefWithData(partyData *types.BATormentPartyData, raidID string) (int, error) { - pool := postgres.InitFromEnv() + pool := gopostgres.InitFromEnv() defer pool.Close() ctx := context.Background() @@ -29,7 +32,7 @@ func UpdateVideoRefWithData(partyData *types.BATormentPartyData, raidID string) } if len(analysisRows) == 0 { - log.Printf("No verified YouTube analysis found for %s", raidID) + ui.Log.Info("No verified YouTube analysis found", logger.F("raidID", raidID)) return 0, nil } @@ -42,7 +45,7 @@ func UpdateVideoRefWithData(partyData *types.BATormentPartyData, raidID string) } updated := matchAndUpdateVideoRefs(partyData, analysisResults, videoIDMap) - log.Printf("Updated %d video references for raid %s", updated, raidID) + ui.Log.Info("Updated video references", logger.F("count", updated), logger.F("raidID", raidID)) return updated, nil } @@ -69,7 +72,7 @@ func matchAndUpdateVideoRefs(partyData *types.BATormentPartyData, analysisResult party.VideoID = &videoID usedVideos[videoID] = true updated++ - log.Printf("Matched party (rank=%d, score=%d) with video: %s", party.Rank, party.Score, videoID) + ui.Log.Info("Matched party with video", logger.F("rank", party.Rank), logger.F("score", party.Score), logger.F("videoID", videoID)) break } } diff --git a/internal/logic/schaledb/i18n.go b/internal/logic/schaledb/i18n.go index 1701a76..6c1345e 100644 --- a/internal/logic/schaledb/i18n.go +++ b/internal/logic/schaledb/i18n.go @@ -3,11 +3,14 @@ package schaledb import ( "context" "encoding/json" - "log" + "fmt" "ba-torment-data-process/internal/constants" "ba-torment-data-process/internal/db/postgres" "ba-torment-data-process/internal/logic/storage" + "ba-torment-data-process/internal/ui" + + "github.com/BeaverHouse/go-common/logger" ) type localizationRaw struct { @@ -21,7 +24,7 @@ func loadLocalizationFull(lang string) *localizationRaw { var data localizationRaw if err := json.Unmarshal(byteValue, &data); err != nil { - log.Fatalf("Failed to unmarshal localization (%s): %v", lang, err) + panic(fmt.Sprintf("Failed to unmarshal localization (%s): %v", lang, err)) } return &data @@ -46,7 +49,7 @@ func SaveI18nData(db *postgres.Queries) error { if err != nil { return err } - log.Printf("Saved i18n school: %s", key) + ui.Log.Info("Saved i18n school", logger.F("key", key)) } // Save Club @@ -61,7 +64,7 @@ func SaveI18nData(db *postgres.Queries) error { if err != nil { return err } - log.Printf("Saved i18n club: %s", key) + ui.Log.Info("Saved i18n club", logger.F("key", key)) } return nil diff --git a/internal/logic/schaledb/presents.go b/internal/logic/schaledb/presents.go index 00b98db..899f678 100644 --- a/internal/logic/schaledb/presents.go +++ b/internal/logic/schaledb/presents.go @@ -8,7 +8,6 @@ import ( "context" "encoding/json" "fmt" - "log" ) // Reads /data//items.json file @@ -19,7 +18,7 @@ func loadItems(lang string) map[string]types.FavorItem { var items map[string]types.FavorItem err := json.Unmarshal(byteValue, &items) if err != nil { - log.Fatalf("Failed to unmarshal localization: %v", err) + panic(fmt.Sprintf("Failed to unmarshal localization: %v", err)) } return items diff --git a/internal/logic/schaledb/students.go b/internal/logic/schaledb/students.go index 5dda7a8..b5c0b2c 100644 --- a/internal/logic/schaledb/students.go +++ b/internal/logic/schaledb/students.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "reflect" "regexp" "strconv" @@ -14,6 +13,9 @@ import ( "ba-torment-data-process/internal/db/postgres" "ba-torment-data-process/internal/logic/storage" "ba-torment-data-process/internal/types" + "ba-torment-data-process/internal/ui" + + "github.com/BeaverHouse/go-common/logger" ) // Localization data structure @@ -34,7 +36,7 @@ func loadLocalization(lang string) map[string]string { var locData LocalizationRawData err := json.Unmarshal(byteValue, &locData) if err != nil { - log.Fatalf("Failed to unmarshal localization: %v", err) + panic(fmt.Sprintf("Failed to unmarshal localization: %v", err)) } return locData.BuffName @@ -48,7 +50,7 @@ func loadJapaneseStudentInfo() map[string]JapaneseStudentInfo { var studentData map[string]JapaneseStudentInfo err := json.Unmarshal(byteValue, &studentData) if err != nil { - log.Fatalf("Failed to unmarshal student data: %v", err) + panic(fmt.Sprintf("Failed to unmarshal student data: %v", err)) } return studentData @@ -198,7 +200,7 @@ func ParseSchaleDBStudents(db *postgres.Queries) (map[string]*types.StudentData, var rawData map[string]any err := json.Unmarshal(byteValue, &rawData) if err != nil { - log.Fatalf("Failed to unmarshal JSON: %v", err) + panic(fmt.Sprintf("Failed to unmarshal JSON: %v", err)) } buffNames := loadLocalization("kr") @@ -210,7 +212,7 @@ func ParseSchaleDBStudents(db *postgres.Queries) (map[string]*types.StudentData, for studentID, studentData := range rawData { dataMap, ok := studentData.(map[string]any) if !ok { - log.Printf("Skipping invalid student data for ID %s", studentID) + ui.Log.Warn("Skipping invalid student data", logger.F("studentID", studentID)) continue } @@ -246,24 +248,24 @@ func ParseSchaleDBStudents(db *postgres.Queries) (map[string]*types.StudentData, // Convert processed map back to CompleteStudentData struct processedJSON, err := json.Marshal(dataMap) if err != nil { - log.Printf("Failed to marshal student data for ID %s: %v", studentID, err) + ui.Log.Warn("Failed to marshal student data", logger.F("studentID", studentID), logger.F("error", err)) continue } var completeData types.StudentData err = json.Unmarshal(processedJSON, &completeData) if err != nil { - log.Printf("Failed to unmarshal student data for ID %s: %v", studentID, err) + ui.Log.Warn("Failed to unmarshal student data", logger.F("studentID", studentID), logger.F("error", err)) continue } studentIDInt64, err := strconv.ParseInt(studentID, 10, 32) if err != nil { - log.Printf("Failed to convert student ID %s to int: %v", studentID, err) + ui.Log.Warn("Failed to convert student ID to int", logger.F("studentID", studentID), logger.F("error", err)) continue } completeDataBytes, err := json.Marshal(completeData) if err != nil { - log.Printf("Failed to marshal student data for ID %s: %v", studentID, err) + ui.Log.Warn("Failed to marshal student data", logger.F("studentID", studentID), logger.F("error", err)) continue } @@ -280,14 +282,14 @@ func ParseSchaleDBStudents(db *postgres.Queries) (map[string]*types.StudentData, // Upload image + wait 3 second. Supabase S3 has performance issue when uploading too many files at once. err = uploadCharacterImage(int(studentIDInt64), false) if err != nil { - log.Printf("Failed to upload image for student %s: %v", studentID, err) + ui.Log.Warn("Failed to upload image for student", logger.F("studentID", studentID), logger.F("error", err)) return nil, err } - log.Printf("Student %s (%s) processed", studentID, completeData.Name) + ui.Log.Info("Student processed", logger.F("studentID", studentID), logger.F("name", completeData.Name)) } if err := storage.MarshalAndUpload(studentMap, "batorment/v3", "student-map.json", false, ""); err != nil { - log.Printf("Failed to upload student map: %v", err) + ui.Log.Warn("Failed to upload student map", logger.F("error", err)) return nil, err } @@ -317,7 +319,7 @@ func ParseSchaleDBStudents(db *postgres.Queries) (map[string]*types.StudentData, } if err := storage.MarshalAndUpload(studentSearchMap, "batorment/v3", "student-search-map.json", false, ""); err != nil { - log.Printf("Failed to upload student search map: %v", err) + ui.Log.Warn("Failed to upload student search map", logger.F("error", err)) return nil, err } diff --git a/internal/logic/storage/download.go b/internal/logic/storage/download.go index 06d3fdc..b6c5f48 100644 --- a/internal/logic/storage/download.go +++ b/internal/logic/storage/download.go @@ -1,32 +1,51 @@ package storage import ( + "fmt" "io" - "log" "net/http" "time" + + "ba-torment-data-process/internal/ui" + + "github.com/BeaverHouse/go-common/logger" ) -// GetDataFromURL gets data from URL. +const maxRetries = 3 + +// GetDataFromURL gets data from URL with retry on transient errors. func GetDataFromURL(url string) []byte { start := time.Now() - resp, err := http.Get(url) - if err != nil { - log.Fatalf("failed to get data from URL: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - log.Fatalf("invalid status code: %d, URL: %s", resp.StatusCode, url) - } + for attempt := range maxRetries { + resp, err := http.Get(url) + if err != nil { + panic(fmt.Sprintf("failed to get data from URL: %v", err)) + } + + if resp.StatusCode >= 500 && attempt < maxRetries-1 { + resp.Body.Close() + ui.Log.Warn("Retrying request", logger.F("url", url), logger.F("status", resp.StatusCode), logger.F("attempt", attempt+1)) + time.Sleep(2 * time.Second) + continue + } + + if resp.StatusCode != 200 { + resp.Body.Close() + panic(fmt.Sprintf("invalid status code: %d, URL: %s", resp.StatusCode, url)) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + panic(fmt.Sprintf("failed to read data from URL: %v", err)) + } else if len(body) == 0 { + panic(fmt.Sprintf("the data from URL is empty: %s", url)) + } - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Fatalf("failed to read data from URL: %v", err) - } else if len(body) == 0 { - log.Fatalf("the data from URL is empty: %s", url) + ui.Log.Info("Response successfully fetched", logger.F("url", url), logger.F("duration", time.Since(start))) + return body } - log.Printf("Response successfully fetched: url=%s, duration=%s", url, time.Since(start)) - return body + panic(fmt.Sprintf("unreachable: max retries exceeded for URL: %s", url)) } diff --git a/internal/logic/storage/upload.go b/internal/logic/storage/upload.go index ff60804..4cf0130 100644 --- a/internal/logic/storage/upload.go +++ b/internal/logic/storage/upload.go @@ -5,12 +5,16 @@ import ( "encoding/json" "fmt" "io" - "log" "mime/multipart" "net/http" "os" "path/filepath" "time" + + "ba-torment-data-process/internal/ui" + + "github.com/BeaverHouse/go-common/env" + "github.com/BeaverHouse/go-common/logger" ) var ( @@ -37,7 +41,7 @@ func MarshalAndUpload(data any, path, fileName string, dryRun bool, successMsg s } if successMsg != "" { - log.Println(successMsg) + ui.Log.Info(successMsg) } return nil @@ -55,10 +59,10 @@ func UploadFile(path string, fileName string, data []byte, dryRun bool) error { part, err := writer.CreateFormFile("file", fileName) if err != nil { - log.Fatalf("Failed to create form file: %v", err) + panic(fmt.Sprintf("Failed to create form file: %v", err)) } if _, err := io.Copy(part, bytes.NewReader(data)); err != nil { - log.Fatalf("Failed to copy file data: %v", err) + panic(fmt.Sprintf("Failed to copy file data: %v", err)) } writer.WriteField("upload_path", path) writer.Close() @@ -69,25 +73,25 @@ func UploadFile(path string, fileName string, data []byte, dryRun bool) error { body, ) if err != nil { - log.Fatalf("API request failed: %v", err) + panic(fmt.Sprintf("API request failed: %v", err)) } - req.Header.Set("X-Access-Token", os.Getenv("BA_ANALYZER_SERVICE_TOKEN")) + req.Header.Set("X-Access-Token", env.GetEnv("BA_ANALYZER_SERVICE_TOKEN", "")) req.Header.Set("Content-Type", writer.FormDataContentType()) client := &http.Client{} resp, err := client.Do(req) if err != nil { - log.Fatalf("API request failed: %v", err) + panic(fmt.Sprintf("API request failed: %v", err)) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - log.Fatalf("failed to upload image: status %d, body: %s", resp.StatusCode, string(body)) + panic(fmt.Sprintf("failed to upload image: status %d, body: %s", resp.StatusCode, string(body))) } - log.Println("File uploaded successfully: ", fileName) + ui.Log.Info("File uploaded successfully", logger.F("file", fileName)) time.Sleep(2 * time.Second) return nil diff --git a/internal/types/config_types.go b/internal/types/config_types.go deleted file mode 100644 index 71b8fb6..0000000 --- a/internal/types/config_types.go +++ /dev/null @@ -1,11 +0,0 @@ -package types - -// Configuration for the Postgres database -type PostgresConfig struct { - Host string - Port int - User string - Password string - DBName string - SSLMode string -} diff --git a/internal/ui/logger.go b/internal/ui/logger.go new file mode 100644 index 0000000..37cf08f --- /dev/null +++ b/internal/ui/logger.go @@ -0,0 +1,5 @@ +package ui + +import "github.com/BeaverHouse/go-common/logger" + +var Log = logger.NewSimpleLogger() diff --git a/main.go b/main.go new file mode 100644 index 0000000..274d552 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "ba-torment-data-process/cmd" + +func main() { + cmd.Execute() +} diff --git a/scripts/setup-mac.sh b/scripts/setup-mac.sh new file mode 100644 index 0000000..56cb9cd --- /dev/null +++ b/scripts/setup-mac.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR/.." + +echo "Building batorment for macOS..." +cd "$PROJECT_DIR" +CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o batorment . +echo "Build complete. Run './batorment' from this directory (with .env file)." diff --git a/scripts/setup-windows.ps1 b/scripts/setup-windows.ps1 new file mode 100644 index 0000000..9e69e55 --- /dev/null +++ b/scripts/setup-windows.ps1 @@ -0,0 +1,12 @@ +# Run in PowerShell + +$ErrorActionPreference = "Stop" + +Write-Host "Building batorment for Windows..." -ForegroundColor Cyan +Push-Location "$PSScriptRoot\.." +$env:CGO_ENABLED = "1" +$env:GOOS = "windows" +$env:GOARCH = "amd64" +go build -o batorment.exe . +Pop-Location +Write-Host "Build complete. Run '.\batorment.exe' from this directory (with .env file)." -ForegroundColor Green