Compare commits
2 Commits
1207a62437
...
ebc0f132db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebc0f132db | ||
|
|
6e2fe461ae |
Binary file not shown.
@@ -108,8 +108,44 @@ def run_migrations():
|
||||
pass # Column already exists
|
||||
|
||||
conn.commit()
|
||||
repair_image_paths(conn)
|
||||
conn.close()
|
||||
|
||||
def repair_image_paths(conn):
|
||||
"""Attempt to populate missing image_path for existing cards in old databases"""
|
||||
print("Checking for missing image paths to repair...")
|
||||
cur = conn.cursor()
|
||||
|
||||
# Find cards with missing image paths but have a URL
|
||||
cur.execute("SELECT card_id, name, gametora_url FROM support_cards WHERE image_path IS NULL OR image_path = ''")
|
||||
to_repair = cur.fetchall()
|
||||
|
||||
if not to_repair:
|
||||
return
|
||||
|
||||
import re
|
||||
repaired_count = 0
|
||||
|
||||
for card_id, name, url in to_repair:
|
||||
if not url: continue
|
||||
|
||||
# Extract ID from URL (e.g., 30154 from .../supports/30154-mejiro-ramonu)
|
||||
match = re.search(r'/supports/(\d+)-', url)
|
||||
if match:
|
||||
stable_id = match.group(1)
|
||||
# Create safe filename matching scraper logic
|
||||
safe_name = re.sub(r'[<>:"/\\\\|?*]', '_', name)
|
||||
filename = f"{stable_id}_{safe_name}.png"
|
||||
|
||||
# Update DB with images/filename
|
||||
cur.execute("UPDATE support_cards SET image_path = ? WHERE card_id = ?",
|
||||
(f"images/{filename}", card_id))
|
||||
repaired_count += 1
|
||||
|
||||
if repaired_count > 0:
|
||||
conn.commit()
|
||||
print(f"Successfully repaired {repaired_count} image paths!")
|
||||
|
||||
def check_for_updates():
|
||||
"""Check if database version matches app version, sync if outdated"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
@@ -486,6 +522,49 @@ def get_unique_effect_names(card_id):
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
def search_owned_effects(search_term):
|
||||
"""
|
||||
Search for effects among owned cards.
|
||||
Returns list of (card_id, card_name, image_path, effect_name, effect_value, level)
|
||||
"""
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# We need to join support_effects with owned_cards to get the level
|
||||
# But wait, owned_cards has a level column. support_effects stores effects for specific levels.
|
||||
# So we need to match support_effects.level with owned_cards.level
|
||||
# OR find the effect for the closest level <= owned level (if effects aren't stored for every single level)
|
||||
# The current DB schema seems to store effects for specific levels (1, 5, 10, ...).
|
||||
# If a card is level 49, `get_effects_at_level` usually queries for exact level match.
|
||||
# Let's check `get_effects_at_level` implementation: "WHERE card_id = ? AND level = ?"
|
||||
# So if I have a card at level 49, and effects are only defined at 45 and 50, query for 49 returns nothing?
|
||||
# That would be a bug or assumption in the current app.
|
||||
# Let's look at `update_progression_table` in `effects_view.py`. It does some "nearest level" logic.
|
||||
# For this search feature, to be robust, we should probably fetch ALL effects for the card
|
||||
# and filter for the one active at the owned level.
|
||||
# OR, assuming the scraper/DB populates "current" effects effectively.
|
||||
# Actually, the most robust way in SQL for "value at level X" given sparse data is complex.
|
||||
# However, let's assume for now we want exact matches or we'll handle the "effective level" logic in Python?
|
||||
# No, that's too slow for search.
|
||||
# Let's look at how `get_effects_at_level` is used.
|
||||
# It is used in `update_current_effects` with `self.level_var.get()`.
|
||||
# It expects an exact match.
|
||||
# So we should probably join on `oc.level`.
|
||||
|
||||
query = """
|
||||
SELECT sc.card_id, sc.name, sc.image_path, se.effect_name, se.effect_value, oc.level
|
||||
FROM owned_cards oc
|
||||
JOIN support_cards sc ON oc.card_id = sc.card_id
|
||||
JOIN support_effects se ON oc.card_id = se.card_id AND oc.level = se.level
|
||||
WHERE se.effect_name LIKE ?
|
||||
ORDER BY sc.name
|
||||
"""
|
||||
|
||||
cur.execute(query, (f"%{search_term}%",))
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
# ============================================
|
||||
# Hint Queries
|
||||
# ============================================
|
||||
|
||||
@@ -1,298 +1,139 @@
|
||||
"""
|
||||
Effects View - Display support effects at all levels with interactive slider
|
||||
Effects Search View - Search for effects across all owned cards
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from db.db_queries import get_all_effects, get_effects_at_level, get_unique_effect_names, get_card_by_id
|
||||
from db.db_queries import search_owned_effects
|
||||
from gui.theme import (
|
||||
BG_DARK, BG_MEDIUM, BG_LIGHT, BG_HIGHLIGHT,
|
||||
ACCENT_PRIMARY, ACCENT_SECONDARY, ACCENT_SUCCESS, ACCENT_TERTIARY,
|
||||
TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED,
|
||||
FONT_HEADER, FONT_SUBHEADER, FONT_BODY, FONT_BODY_BOLD, FONT_SMALL, FONT_MONO,
|
||||
create_styled_button, create_styled_text, create_card_frame,
|
||||
EFFECT_DESCRIPTIONS
|
||||
FONT_HEADER, FONT_SUBHEADER, FONT_BODY, FONT_BODY_BOLD, FONT_SMALL,
|
||||
create_styled_button, create_styled_entry
|
||||
)
|
||||
|
||||
from utils import resolve_image_path
|
||||
|
||||
class EffectsFrame(ttk.Frame):
|
||||
"""Frame for viewing support effects at different levels"""
|
||||
"""Frame for searching effects across owned cards"""
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
self.current_card_id = None
|
||||
self.current_card_name = None
|
||||
self.max_level = 50
|
||||
|
||||
self.create_widgets()
|
||||
|
||||
def create_widgets(self):
|
||||
"""Create the effects view interface"""
|
||||
# Header
|
||||
"""Create the effects search interface"""
|
||||
# Header / Search Bar
|
||||
header_frame = tk.Frame(self, bg=BG_DARK)
|
||||
header_frame.pack(fill=tk.X, padx=20, pady=15)
|
||||
|
||||
self.card_label = tk.Label(header_frame, text="📊 Select a card from the Card List tab",
|
||||
font=FONT_HEADER, bg=BG_DARK, fg=ACCENT_PRIMARY)
|
||||
self.card_label.pack(side=tk.LEFT)
|
||||
# Search container
|
||||
search_container = tk.Frame(header_frame, bg=BG_DARK)
|
||||
search_container.pack(fill=tk.X)
|
||||
|
||||
# Legend Button
|
||||
legend_btn = create_styled_button(header_frame, text="❓ Legend",
|
||||
command=self.show_legend, style_type='default')
|
||||
legend_btn.config(font=FONT_SMALL, padx=10, pady=4)
|
||||
legend_btn.pack(side=tk.RIGHT)
|
||||
tk.Label(search_container, text="🔍 Search Effect:",
|
||||
font=FONT_HEADER, bg=BG_DARK, fg=TEXT_PRIMARY).pack(side=tk.LEFT, padx=(0, 10))
|
||||
|
||||
# Level control frame
|
||||
control_frame = tk.Frame(self, bg=BG_MEDIUM, padx=15, pady=12)
|
||||
control_frame.pack(fill=tk.X, padx=20)
|
||||
self.search_var = tk.StringVar()
|
||||
self.search_entry = create_styled_entry(search_container, textvariable=self.search_var)
|
||||
self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
|
||||
self.search_entry.bind('<Return>', lambda e: self.perform_search())
|
||||
|
||||
# Level label
|
||||
tk.Label(control_frame, text="Level:", font=FONT_BODY,
|
||||
bg=BG_MEDIUM, fg=TEXT_SECONDARY).pack(side=tk.LEFT)
|
||||
search_btn = create_styled_button(search_container, text="Search",
|
||||
command=self.perform_search, style_type='primary')
|
||||
search_btn.pack(side=tk.LEFT)
|
||||
|
||||
# Level display with increment/decrement buttons
|
||||
level_ctrl = tk.Frame(control_frame, bg=BG_MEDIUM)
|
||||
level_ctrl.pack(side=tk.LEFT, padx=15)
|
||||
# Example/Help text
|
||||
help_frame = tk.Frame(header_frame, bg=BG_DARK)
|
||||
help_frame.pack(fill=tk.X, pady=(5, 0))
|
||||
tk.Label(help_frame, text="Examples: Friendship, Motivation, Race Bonus, Skill Pt",
|
||||
font=FONT_SMALL, bg=BG_DARK, fg=TEXT_MUTED).pack(side=tk.LEFT)
|
||||
|
||||
# Decrement button
|
||||
dec_btn = tk.Button(level_ctrl, text="−", font=FONT_HEADER,
|
||||
bg=BG_LIGHT, fg=TEXT_PRIMARY, bd=0, width=2,
|
||||
activebackground=BG_HIGHLIGHT, cursor='hand2',
|
||||
command=self.decrement_level)
|
||||
dec_btn.pack(side=tk.LEFT)
|
||||
# Results Area
|
||||
results_frame = ttk.LabelFrame(self, text=" Search Results (Owned Cards) ", padding=10)
|
||||
results_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=(0, 20))
|
||||
|
||||
self.level_var = tk.IntVar(value=50)
|
||||
self.level_display = tk.Label(level_ctrl, text="50", width=4, font=FONT_HEADER,
|
||||
bg=BG_LIGHT, fg=ACCENT_PRIMARY, padx=10)
|
||||
self.level_display.pack(side=tk.LEFT, padx=2)
|
||||
# Treeview
|
||||
columns = ('card', 'level', 'current_value', 'effect_name')
|
||||
self.tree = ttk.Treeview(results_frame, columns=columns, show='headings', selectmode='browse')
|
||||
|
||||
# Increment button
|
||||
inc_btn = tk.Button(level_ctrl, text="+", font=FONT_HEADER,
|
||||
bg=BG_LIGHT, fg=TEXT_PRIMARY, bd=0, width=2,
|
||||
activebackground=BG_HIGHLIGHT, cursor='hand2',
|
||||
command=self.increment_level)
|
||||
inc_btn.pack(side=tk.LEFT)
|
||||
self.tree.heading('card', text='Card Name', anchor='w')
|
||||
self.tree.heading('level', text='Level', anchor='center')
|
||||
self.tree.heading('current_value', text='Value', anchor='center')
|
||||
self.tree.heading('effect_name', text='Effect Name', anchor='w')
|
||||
|
||||
# Quick level buttons
|
||||
button_frame = tk.Frame(control_frame, bg=BG_MEDIUM)
|
||||
button_frame.pack(side=tk.LEFT, padx=25)
|
||||
self.tree.column('card', width=250)
|
||||
self.tree.column('level', width=60, anchor='center')
|
||||
self.tree.column('current_value', width=80, anchor='center')
|
||||
self.tree.column('effect_name', width=150)
|
||||
|
||||
quick_levels = [25, 30, 35, 40, 45, 50]
|
||||
for lvl in quick_levels:
|
||||
btn = create_styled_button(button_frame, text=f"Lv{lvl}",
|
||||
command=lambda l=lvl: self.set_level(l),
|
||||
style_type='default')
|
||||
btn.config(width=5, font=FONT_SMALL, padx=6, pady=3)
|
||||
btn.pack(side=tk.LEFT, padx=3)
|
||||
scrollbar = ttk.Scrollbar(results_frame, orient=tk.VERTICAL, command=self.tree.yview)
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
# Main content area
|
||||
content_frame = tk.Frame(self, bg=BG_DARK)
|
||||
content_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=15)
|
||||
|
||||
# Left: Current level effects
|
||||
left_frame = ttk.LabelFrame(content_frame, text=" Current Level Effects ", padding=12)
|
||||
left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10))
|
||||
|
||||
self.current_effects = create_styled_text(left_frame, height=15)
|
||||
self.current_effects.pack(fill=tk.BOTH, expand=True)
|
||||
self.current_effects.config(state=tk.DISABLED)
|
||||
|
||||
# Right: Effect progression table
|
||||
right_frame = ttk.LabelFrame(content_frame, text=" Effect Progression ", padding=12)
|
||||
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# Treeview for effect table
|
||||
columns = ('effect', 'lv1', 'lv25', 'lv40', 'lv50')
|
||||
self.effects_tree = ttk.Treeview(right_frame, columns=columns, show='headings', height=12)
|
||||
|
||||
self.effects_tree.heading('effect', text='Effect', anchor='w')
|
||||
self.effects_tree.column('effect', width=140, minwidth=120)
|
||||
|
||||
for col in columns[1:]:
|
||||
level = col.replace('lv', 'Lv ')
|
||||
self.effects_tree.heading(col, text=level)
|
||||
self.effects_tree.column(col, width=65, anchor='center')
|
||||
|
||||
scrollbar = ttk.Scrollbar(right_frame, orient=tk.VERTICAL, command=self.effects_tree.yview)
|
||||
self.effects_tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
self.effects_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
def show_legend(self):
|
||||
"""Show effect explanations"""
|
||||
legend = {
|
||||
"Friendship Bonus": "Increases stats gained when training with this support card during Friendship Training (orange aura).",
|
||||
"Motivation Bonus": "Increases stats gained based on your Uma's motivation level.",
|
||||
"Specialty Rate": "Increases the chance of this card appearing in its specialty training.",
|
||||
"Training Bonus": "Flat percentage increase to stats gained in training where this card is present.",
|
||||
"Initial Bond": "Starting gauge value for this card.",
|
||||
"Race Bonus": "Increases stats gained from racing.",
|
||||
"Fan Count Bonus": "Increases fans gained from racing.",
|
||||
"Skill Pt Bonus": "Bonus skill points gained when training with this card.",
|
||||
"Hint Lv": "Starting level of skills taught by this card's hints.",
|
||||
"Hint Rate": "Increases chance of getting a hint event."
|
||||
}
|
||||
# Status Label
|
||||
self.status_label = tk.Label(results_frame, text="", bg=BG_MEDIUM, fg=TEXT_SECONDARY, font=FONT_SMALL)
|
||||
self.status_label.pack(side=tk.BOTTOM, fill=tk.X, pady=(5, 0))
|
||||
|
||||
text = "📖 Effect Explanations:\n\n"
|
||||
for name, desc in legend.items():
|
||||
text += f"• {name}:\n {desc}\n\n"
|
||||
def parse_value(self, value_str):
|
||||
"""Parse effect value string to float for sorting"""
|
||||
try:
|
||||
# Extract number from string (e.g. "20%" -> 20, "+15" -> 15)
|
||||
# Remove non-numeric characters except . and -
|
||||
clean = re.sub(r'[^\d.-]', '', str(value_str))
|
||||
return float(clean)
|
||||
except:
|
||||
return -999999.0 # Sort to bottom if invalid
|
||||
|
||||
messagebox.showinfo("Effect Legend", text)
|
||||
def perform_search(self):
|
||||
"""Execute search and update results"""
|
||||
term = self.search_var.get().strip()
|
||||
if not term:
|
||||
messagebox.showwarning("Search", "Please enter a search term")
|
||||
return
|
||||
|
||||
# clear current
|
||||
for item in self.tree.get_children():
|
||||
self.tree.delete(item)
|
||||
|
||||
# Query DB
|
||||
results = search_owned_effects(term)
|
||||
|
||||
if not results:
|
||||
self.status_label.config(text="No matching effects found among owned cards.")
|
||||
return
|
||||
|
||||
# Process and Sort
|
||||
# Row: (card_id, card_name, image_path, effect_name, effect_value, level)
|
||||
processed_results = []
|
||||
for r in results:
|
||||
val_num = self.parse_value(r[4])
|
||||
processed_results.append({
|
||||
'data': r,
|
||||
'sort_val': val_num
|
||||
})
|
||||
|
||||
# Sort by value descending
|
||||
processed_results.sort(key=lambda x: x['sort_val'], reverse=True)
|
||||
|
||||
# Populate Tree
|
||||
for item in processed_results:
|
||||
r = item['data']
|
||||
#Columns: card, level, current_value, effect_name
|
||||
values = (r[1], f"Lv {r[5]}", r[4], r[3])
|
||||
self.tree.insert('', tk.END, values=values)
|
||||
|
||||
self.status_label.config(text=f"Found {len(processed_results)} owned cards with matching effects.")
|
||||
|
||||
# Compatibility methods for main_window integration (empty as we don't need them anymore)
|
||||
def set_card(self, card_id):
|
||||
"""Load a card's effects"""
|
||||
self.current_card_id = card_id
|
||||
pass
|
||||
|
||||
# Get card info for max level
|
||||
card = get_card_by_id(card_id)
|
||||
if card:
|
||||
self.current_card_name = card[1]
|
||||
self.max_level = card[4]
|
||||
if self.level_var.get() > self.max_level:
|
||||
self.level_var.set(self.max_level)
|
||||
self.level_display.config(text=str(self.max_level))
|
||||
|
||||
self.card_label.config(text=f"📊 {self.current_card_name}")
|
||||
|
||||
# Update displays
|
||||
self.update_current_effects()
|
||||
self.update_progression_table()
|
||||
|
||||
def set_level(self, level):
|
||||
"""Set level from quick button"""
|
||||
if level <= self.max_level:
|
||||
self.level_var.set(level)
|
||||
self.level_display.config(text=str(level))
|
||||
self.update_current_effects()
|
||||
|
||||
def increment_level(self):
|
||||
"""Increase level by 1"""
|
||||
current = self.level_var.get()
|
||||
if current < self.max_level:
|
||||
self.set_level(current + 1)
|
||||
|
||||
def decrement_level(self):
|
||||
"""Decrease level by 1"""
|
||||
current = self.level_var.get()
|
||||
if current > 1:
|
||||
self.set_level(current - 1)
|
||||
|
||||
def update_current_effects(self):
|
||||
"""Update the current level effects display"""
|
||||
self.current_effects.config(state=tk.NORMAL)
|
||||
self.current_effects.delete('1.0', tk.END)
|
||||
|
||||
# Configure tags
|
||||
self.current_effects.tag_configure('header', font=FONT_SUBHEADER, foreground=ACCENT_PRIMARY)
|
||||
self.current_effects.tag_configure('highlight', foreground=ACCENT_SUCCESS)
|
||||
self.current_effects.tag_configure('effect_name', foreground=TEXT_SECONDARY)
|
||||
self.current_effects.tag_configure('effect_value', foreground=TEXT_PRIMARY, font=FONT_BODY_BOLD)
|
||||
self.current_effects.tag_configure('effect_tooltip', underline=False)
|
||||
|
||||
if not self.current_card_id:
|
||||
self.current_effects.insert(tk.END, "No card selected\n\n", 'effect_name')
|
||||
self.current_effects.insert(tk.END, "Select a card from the Card List tab to view its effects.", 'effect_name')
|
||||
self.current_effects.config(state=tk.DISABLED)
|
||||
return
|
||||
|
||||
level = self.level_var.get()
|
||||
effects = get_effects_at_level(self.current_card_id, level)
|
||||
|
||||
self.current_effects.insert(tk.END, f"━━━ Level {level} ━━━\n\n", 'header')
|
||||
|
||||
if effects:
|
||||
for name, value in effects:
|
||||
# Highlight high values
|
||||
prefix = ""
|
||||
if '%' in str(value):
|
||||
try:
|
||||
num = int(str(value).replace('%', '').replace('+', ''))
|
||||
if num >= 20:
|
||||
prefix = "★ "
|
||||
except:
|
||||
pass
|
||||
|
||||
if prefix:
|
||||
self.current_effects.insert(tk.END, prefix, 'highlight')
|
||||
|
||||
# Insert effect name with tooltip tag
|
||||
tag_name = f"tooltip_{name.replace(' ', '_')}"
|
||||
self.current_effects.insert(tk.END, f"{name}: ", ('effect_name', tag_name))
|
||||
|
||||
# Bind tooltip events
|
||||
self.current_effects.tag_bind(tag_name, "<Enter>", lambda e, n=name: self.show_effect_tooltip(e, n))
|
||||
self.current_effects.tag_bind(tag_name, "<Leave>", self.hide_effect_tooltip)
|
||||
|
||||
self.current_effects.insert(tk.END, f"{value}\n", 'effect_value')
|
||||
else:
|
||||
self.current_effects.insert(tk.END, "No effect data available for this level.\n\n", 'effect_name')
|
||||
self.current_effects.insert(tk.END, "Try selecting: Lv 1, 25, 40, or 50", 'effect_name')
|
||||
|
||||
self.current_effects.config(state=tk.DISABLED)
|
||||
|
||||
def show_effect_tooltip(self, event, effect_name):
|
||||
"""Show tooltip for effect"""
|
||||
if effect_name in EFFECT_DESCRIPTIONS:
|
||||
text = EFFECT_DESCRIPTIONS[effect_name]
|
||||
x = event.x_root + 15
|
||||
y = event.y_root + 10
|
||||
|
||||
# Close existing if any
|
||||
self.hide_effect_tooltip(None)
|
||||
|
||||
self.tooltip_window = tk.Toplevel(self)
|
||||
self.tooltip_window.wm_overrideredirect(True)
|
||||
self.tooltip_window.wm_geometry(f"+{x}+{y}")
|
||||
|
||||
label = tk.Label(self.tooltip_window, text=text, justify=tk.LEFT,
|
||||
background=BG_LIGHT, foreground=TEXT_PRIMARY,
|
||||
relief=tk.SOLID, borderwidth=1, font=FONT_SMALL,
|
||||
padx=10, pady=5, wraplength=250)
|
||||
label.pack()
|
||||
|
||||
def hide_effect_tooltip(self, event):
|
||||
"""Hide tooltip"""
|
||||
if hasattr(self, 'tooltip_window') and self.tooltip_window:
|
||||
self.tooltip_window.destroy()
|
||||
self.tooltip_window = None
|
||||
|
||||
def update_progression_table(self):
|
||||
"""Update the effect progression table"""
|
||||
self.effects_tree.delete(*self.effects_tree.get_children())
|
||||
|
||||
if not self.current_card_id:
|
||||
return
|
||||
|
||||
# Get all effects
|
||||
all_effects = get_all_effects(self.current_card_id)
|
||||
|
||||
# Organize by effect name
|
||||
effect_by_level = {}
|
||||
for level, effect_name, effect_value in all_effects:
|
||||
if effect_name not in effect_by_level:
|
||||
effect_by_level[effect_name] = {}
|
||||
effect_by_level[effect_name][level] = effect_value
|
||||
|
||||
# Key levels for the table
|
||||
key_levels = [1, 25, 40, 50]
|
||||
|
||||
# Add rows
|
||||
for effect_name, levels in sorted(effect_by_level.items()):
|
||||
row = [effect_name]
|
||||
for lvl in key_levels:
|
||||
# Find closest level we have data for
|
||||
value = levels.get(lvl, '')
|
||||
if not value:
|
||||
# Try to find nearest
|
||||
for l in sorted(levels.keys()):
|
||||
if l <= lvl:
|
||||
value = levels[l]
|
||||
row.append(value)
|
||||
|
||||
self.effects_tree.insert('', tk.END, values=row)
|
||||
|
||||
11
gui/theme.py
11
gui/theme.py
@@ -256,6 +256,17 @@ def configure_styles(root: tk.Tk):
|
||||
# WIDGET HELPER FUNCTIONS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def create_styled_entry(parent, textvariable=None, **kwargs):
|
||||
"""Create a styled tk.Entry with modern appearance"""
|
||||
entry = ttk.Entry(
|
||||
parent,
|
||||
textvariable=textvariable,
|
||||
font=FONT_BODY,
|
||||
**kwargs
|
||||
)
|
||||
return entry
|
||||
|
||||
def create_styled_button(parent, text, command=None, style_type='default', **kwargs):
|
||||
"""Create a styled tk.Button with modern appearance"""
|
||||
bg_colors = {
|
||||
|
||||
@@ -549,242 +549,200 @@ def scrape_hints(page, card_id, cur):
|
||||
print(f" Found {len(hints)} hints")
|
||||
|
||||
def scrape_events(page, card_id, cur):
|
||||
"""Scrape the LAST chain event (Golden Perk) with OR options"""
|
||||
"""Scrape all events including Chain, Dates, Random and Special"""
|
||||
|
||||
# Use a flag to avoid adding multiple console listeners
|
||||
if not hasattr(page, "_console_attached"):
|
||||
page.on("console", lambda msg: print(f" [JS Console] {msg.text}") if "scrapping" not in msg.text.lower() else None)
|
||||
page._console_attached = True
|
||||
|
||||
# 1. First, build a map of skills from the 'Skills from events' summary section
|
||||
# This helps us identify which skills are Rare (Gold)
|
||||
# 1. Build a map of skills from the 'Skills from events' summary section
|
||||
# This remains useful for identifying golden skills.
|
||||
skill_rarity_map = page.evaluate("""
|
||||
() => {
|
||||
const map = {};
|
||||
console.log("Building Skill Rarity Map...");
|
||||
|
||||
// 1. Find all skill containers. They usually have a name and a 'Details' button.
|
||||
// In the "Skills from events" or "Support hints" sections.
|
||||
const containers = Array.from(document.querySelectorAll('div')).filter(d =>
|
||||
(d.innerText.includes('Details') || d.innerText.includes('Reward')) && d.innerText.length < 500
|
||||
);
|
||||
|
||||
containers.forEach(c => {
|
||||
// Try to extract the skill name. It's usually the first text node or a bold tag.
|
||||
const nameNode = c.querySelector('b, span[font-weight="bold"], div[font-weight="bold"]');
|
||||
let name = "";
|
||||
if (nameNode) {
|
||||
name = nameNode.innerText.trim();
|
||||
} else {
|
||||
// Fallback to text before 'Details'
|
||||
name = c.innerText.split('Details')[0].replace(/\\n/g, ' ').trim();
|
||||
}
|
||||
let name = nameNode ? nameNode.innerText.trim() : c.innerText.split('Details')[0].replace(/\\n/g, ' ').trim();
|
||||
|
||||
if (name && name.length > 2) {
|
||||
const style = window.getComputedStyle(c);
|
||||
const nameStyle = nameNode ? window.getComputedStyle(nameNode) : null;
|
||||
|
||||
// Golden skills have a specific background
|
||||
const isGold = style.backgroundImage.includes('linear-gradient') ||
|
||||
style.backgroundColor.includes('rgb(255, 193, 7)') ||
|
||||
(nameStyle && nameStyle.color === 'rgb(255, 193, 7)') ||
|
||||
c.className.includes('kkspcu') ||
|
||||
c.innerHTML.includes('kkspcu');
|
||||
c.className.includes('kkspcu');
|
||||
|
||||
const normalized = name.toLowerCase().replace(/\\s+/g, ' ').replace(/[()()-]/g, '').trim();
|
||||
map[normalized] = isGold;
|
||||
console.log(`Mapped Skill: "${name}" [${normalized}] -> Gold: ${isGold}`);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
""")
|
||||
|
||||
# Scroll to the Events section specifically
|
||||
print(" Ensuring events are loaded...")
|
||||
page.evaluate("() => { const h = Array.from(document.querySelectorAll('h2, h1, div')).find(el => el.innerText.toLowerCase().includes('training events')); if (h) h.scrollIntoView(); }")
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# 2. Scrape ONLY the LAST chain event (Golden Perk) with OR options
|
||||
golden_perk_data = page.evaluate("""
|
||||
# 2. Scrape all event types
|
||||
print(" Scraping all event categories...")
|
||||
all_events_data = page.evaluate("""
|
||||
async () => {
|
||||
console.log("Scraping Golden Perk (last chain event)...");
|
||||
const results = [];
|
||||
|
||||
// Find all chain event buttons
|
||||
const getChainEventButtons = () => {
|
||||
const buttons = [];
|
||||
// Look for "Chain Events" text (case-insensitive substring)
|
||||
const labels = Array.from(document.querySelectorAll('div, span, h2, h3, h4')).filter(el =>
|
||||
el.innerText.toLowerCase().includes('chain events') && el.innerText.trim().length < 20
|
||||
// Define categories to look for
|
||||
const categories = [
|
||||
{ label: 'Chain Events', type: 'Chain' },
|
||||
{ label: 'Dates', type: 'Date' },
|
||||
{ label: 'Random Events', type: 'Random' },
|
||||
{ label: 'Special Events', type: 'Special' }
|
||||
];
|
||||
|
||||
for (const cat of categories) {
|
||||
// Find category headers
|
||||
const headers = Array.from(document.querySelectorAll('div, span, h2, h3, h4')).filter(el =>
|
||||
el.innerText.trim() === cat.label && el.children.length === 0
|
||||
);
|
||||
|
||||
labels.forEach(label => {
|
||||
// The buttons are usually in the same container or next container
|
||||
let container = label.parentElement;
|
||||
for (const header of headers) {
|
||||
// Find buttons in the following siblings or parent siblings
|
||||
let container = header.parentElement;
|
||||
let foundButtons = [];
|
||||
let attempts = 0;
|
||||
while (container && container.querySelectorAll('button').length === 0 && attempts < 5) {
|
||||
container = container.nextElementSibling || container.parentElement;
|
||||
attempts++;
|
||||
if (container && container.tagName === 'BODY') break;
|
||||
}
|
||||
|
||||
if (container) {
|
||||
const btns = Array.from(container.querySelectorAll('button'));
|
||||
btns.forEach(btn => {
|
||||
const text = btn.innerText.trim();
|
||||
while (container && foundButtons.length === 0 && attempts < 5) {
|
||||
foundButtons = Array.from(container.querySelectorAll('button')).filter(btn => {
|
||||
const style = window.getComputedStyle(btn);
|
||||
const isVisible = style.display !== 'none' && style.visibility !== 'hidden';
|
||||
|
||||
// Look for arrows (regular or heavy)
|
||||
if (isVisible && (text.includes('>') || text.includes('❯'))) {
|
||||
buttons.push(btn);
|
||||
}
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
});
|
||||
|
||||
if (foundButtons.length === 0) {
|
||||
// Check next siblings of the header's ancestors
|
||||
let sibling = header;
|
||||
let parent = header.parentElement;
|
||||
while(parent && parent.tagName !== 'BODY') {
|
||||
if (parent.innerText.includes(cat.label)) {
|
||||
const next = parent.nextElementSibling;
|
||||
if (next) {
|
||||
const nextBtns = Array.from(next.querySelectorAll('button'));
|
||||
if (nextBtns.length > 0) {
|
||||
foundButtons = nextBtns;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
container = container.parentElement;
|
||||
attempts++;
|
||||
}
|
||||
});
|
||||
return buttons;
|
||||
};
|
||||
|
||||
const buttons = getChainEventButtons();
|
||||
console.log(`Found ${buttons.length} chain event buttons`);
|
||||
// Scrape each button in the category
|
||||
for (const btn of foundButtons) {
|
||||
const eventName = btn.innerText.trim();
|
||||
if (!eventName || results.some(r => r.name === eventName)) continue;
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Count arrows for chain/date importance
|
||||
const arrows = (eventName.match(/>|❯/g) || []).length;
|
||||
|
||||
let goldenPerkButton = null;
|
||||
let maxArrows = 0;
|
||||
try {
|
||||
btn.scrollIntoViewIfNeeded ? btn.scrollIntoViewIfNeeded() : null;
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
btn.click();
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
for (const btn of buttons) {
|
||||
const text = btn.innerText.trim();
|
||||
// Count both regular and heavy arrows
|
||||
const arrowCount = (text.match(/>|❯/g) || []).length;
|
||||
const popovers = Array.from(document.querySelectorAll('div')).filter(d =>
|
||||
d.innerText.includes(eventName) &&
|
||||
window.getComputedStyle(d).zIndex > 50 &&
|
||||
d.innerText.length < 2500
|
||||
);
|
||||
|
||||
// If it has three heavy arrows, it's almost certainly the golden perk
|
||||
if (text.includes('❯❯❯')) {
|
||||
goldenPerkButton = btn;
|
||||
break;
|
||||
}
|
||||
if (popovers.length > 0) {
|
||||
const pop = popovers[popovers.length - 1];
|
||||
const hasOrDivider = pop.querySelector('[class*="divider_or"]') !== null ||
|
||||
pop.innerText.includes('Randomly either') ||
|
||||
pop.innerText.toLowerCase().includes(' or ');
|
||||
|
||||
if (arrowCount > maxArrows) {
|
||||
maxArrows = arrowCount;
|
||||
goldenPerkButton = btn;
|
||||
}
|
||||
}
|
||||
const skillLinks = Array.from(pop.querySelectorAll('span, a')).filter(el =>
|
||||
el.innerText.length > 2 &&
|
||||
!el.innerText.includes('Energy') &&
|
||||
(window.getComputedStyle(el).color === 'rgb(102, 107, 255)' ||
|
||||
el.className.includes('linkcolor'))
|
||||
);
|
||||
|
||||
if (!goldenPerkButton) {
|
||||
console.log("No golden perk button found");
|
||||
return null;
|
||||
}
|
||||
const skills = [];
|
||||
skillLinks.forEach(link => {
|
||||
const sName = link.innerText.trim();
|
||||
if (sName && !skills.some(s => s.name === sName)) {
|
||||
skills.push({ name: sName, is_or: hasOrDivider });
|
||||
}
|
||||
});
|
||||
|
||||
const eventName = goldenPerkButton.innerText.trim();
|
||||
console.log(`Found Golden Perk: ${eventName} (${maxArrows} arrows)`);
|
||||
results.push({
|
||||
name: eventName,
|
||||
type: cat.type,
|
||||
skills: skills,
|
||||
arrows: arrows
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Click to open popover
|
||||
goldenPerkButton.scrollIntoViewIfNeeded ? goldenPerkButton.scrollIntoViewIfNeeded() : null;
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
goldenPerkButton.click();
|
||||
await new Promise(r => setTimeout(r, 600));
|
||||
|
||||
// Find popover
|
||||
const popovers = Array.from(document.querySelectorAll('div')).filter(d =>
|
||||
d.innerText.includes(eventName) &&
|
||||
window.getComputedStyle(d).zIndex > 50 &&
|
||||
d.innerText.length < 2500
|
||||
);
|
||||
|
||||
if (popovers.length === 0) {
|
||||
console.log(`Popover NOT found for ${eventName}`);
|
||||
document.body.click();
|
||||
return { name: eventName, type: 'Chain', skills: [] };
|
||||
}
|
||||
|
||||
const pop = popovers[popovers.length - 1];
|
||||
console.log(`Found popover for ${eventName}`);
|
||||
|
||||
// Check for OR structure - look for "Randomly either" or "or" divider
|
||||
const hasOrDivider = pop.querySelector('[class*="divider_or"]') !== null ||
|
||||
pop.innerText.includes('Randomly either') ||
|
||||
pop.innerText.toLowerCase().includes(' or ');
|
||||
|
||||
// Find all skill names (purple/blue links)
|
||||
const skillLinks = Array.from(pop.querySelectorAll('span, a')).filter(el =>
|
||||
el.innerText.length > 2 &&
|
||||
!el.innerText.includes('Energy') &&
|
||||
!el.innerText.includes('bond') &&
|
||||
(window.getComputedStyle(el).color === 'rgb(102, 107, 255)' ||
|
||||
el.className.includes('linkcolor'))
|
||||
);
|
||||
|
||||
console.log(`Found ${skillLinks.length} potential skills in popover`);
|
||||
|
||||
const skills = [];
|
||||
skillLinks.forEach(link => {
|
||||
const skillName = link.innerText.trim();
|
||||
if (skillName && skillName.length > 2 && !skills.some(s => s.name === skillName)) {
|
||||
// If there's an OR divider, all skills in this popover are part of OR groups
|
||||
const isOr = hasOrDivider;
|
||||
skills.push({ name: skillName, is_or: isOr });
|
||||
document.body.click();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
} catch (err) {
|
||||
console.log(`Failed to scrape event ${eventName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close popover
|
||||
document.body.click();
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
return { name: eventName, type: 'Chain', skills: skills };
|
||||
|
||||
} catch (err) {
|
||||
console.log(`Error clicking ${eventName}: ${err.message}`);
|
||||
return { name: eventName, type: 'Chain', skills: [] };
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
""")
|
||||
|
||||
# 3. Store ONLY the golden perk in database
|
||||
if golden_perk_data:
|
||||
cur.execute("""
|
||||
INSERT INTO support_events (card_id, event_name, event_type)
|
||||
VALUES (?, ?, ?)
|
||||
""", (card_id, golden_perk_data['name'], golden_perk_data['type']))
|
||||
event_id = cur.lastrowid
|
||||
# 3. Store all found events and identify golden skills
|
||||
if all_events_data:
|
||||
# Determine max arrows for Chain and Dates to identify final step
|
||||
max_arrows = {
|
||||
'Chain': max([e['arrows'] for e in all_events_data if e['type'] == 'Chain'] + [0]),
|
||||
'Date': max([e['arrows'] for e in all_events_data if e['type'] == 'Date'] + [0])
|
||||
}
|
||||
|
||||
for skill in golden_perk_data['skills']:
|
||||
# Normalization helper
|
||||
def normalize(s):
|
||||
return s.lower().replace(" hint +1", "").replace(" hint +3", "").replace(" hint +5", "").replace(" hint +", "").strip().replace(" ", " ").replace("-", "").replace("(", "").replace(")", "").replace(" ", "")
|
||||
for event in all_events_data:
|
||||
cur.execute("INSERT INTO support_events (card_id, event_name, event_type) VALUES (?, ?, ?)",
|
||||
(card_id, event['name'], event['type']))
|
||||
event_id = cur.lastrowid
|
||||
|
||||
skill_name = normalize(skill['name'])
|
||||
for skill in event['skills']:
|
||||
def normalize(s):
|
||||
# Remove hint suffix and special characters
|
||||
s = s.lower().split(' hint +')[0]
|
||||
return re.sub(r'[()()\-\s\+]', '', s).strip()
|
||||
|
||||
# Use extra aggressive name matching against the map values
|
||||
# (The map keys are already normalized)
|
||||
is_gold = 0
|
||||
for k, gold in skill_rarity_map.items():
|
||||
if normalize(k) == skill_name:
|
||||
is_gold = 1 if gold else 0
|
||||
break
|
||||
n_name = normalize(skill['name'])
|
||||
is_gold = 0
|
||||
for k, gold in skill_rarity_map.items():
|
||||
if normalize(k) == n_name:
|
||||
is_gold = 1 if gold else 0
|
||||
break
|
||||
|
||||
# Fallback 1: If it's a chain event and specifically the last one, it's almost certainly gold
|
||||
if not is_gold and golden_perk_data.get('type') == 'Chain':
|
||||
# Check for "hint" patterns which usually accompany gold perks in chain events
|
||||
if "hint +" in skill['name'].lower() or len(golden_perk_data['skills']) <= 2:
|
||||
is_gold = 1
|
||||
print(f" ✨ Golden Skill Fallback (Last Chain Event): {skill['name']}")
|
||||
# Heuristic: If it's the last step of a Chain or Date, it's likely gold
|
||||
if not is_gold and event['type'] in ['Chain', 'Date']:
|
||||
if event['arrows'] >= 3 and event['arrows'] == max_arrows[event['type']]:
|
||||
if len(event['skills']) <= 2 or "hint +" in skill['name'].lower():
|
||||
is_gold = 1
|
||||
print(f" ✨ Heuristic Gold: {skill['name']} in {event['name']}")
|
||||
|
||||
if is_gold:
|
||||
print(f" ✨ Golden Skill Verified: {skill['name']}")
|
||||
if is_gold: print(f" ✨ Verified Gold: {skill['name']}")
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO event_skills (event_id, skill_name, is_gold, is_or)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (event_id, skill['name'], is_gold, 1 if skill['is_or'] else 0))
|
||||
cur.execute("""
|
||||
INSERT INTO event_skills (event_id, skill_name, is_gold, is_or)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (event_id, skill['name'], is_gold, 1 if skill['is_or'] else 0))
|
||||
|
||||
skill_count = len(golden_perk_data['skills'])
|
||||
or_count = sum(1 for s in golden_perk_data['skills'] if s['is_or'])
|
||||
print(f" Golden Perk: {golden_perk_data['name']} ({skill_count} skills, {or_count} with OR)")
|
||||
print(f" Scraped {len(all_events_data)} total events.")
|
||||
else:
|
||||
print(f" No Golden Perk found for this card")
|
||||
print(f" No events found.")
|
||||
|
||||
def run_scraper():
|
||||
""" Run the web scraper to fetch card data from GameTora.com """
|
||||
|
||||
@@ -4,7 +4,7 @@ This file is the single source of truth for the application version.
|
||||
"""
|
||||
|
||||
# Semantic versioning: MAJOR.MINOR.PATCH
|
||||
VERSION: str = "13.0.3"
|
||||
VERSION: str = "13.0.6"
|
||||
|
||||
# Application metadata
|
||||
APP_NAME: str = "UmamusumeCardManager"
|
||||
|
||||
Reference in New Issue
Block a user