Skip to content

Instantly share code, notes, and snippets.

@eros18123
Created February 12, 2026 14:40
Show Gist options
  • Select an option

  • Save eros18123/a78287851f991362a847c7faa1dd89cb to your computer and use it in GitHub Desktop.

Select an option

Save eros18123/a78287851f991362a847c7faa1dd89cb to your computer and use it in GitHub Desktop.
fixed decks 5.9
# __init__.py
import os
import re
import json
import itertools
import math
import webbrowser
import tempfile
import datetime
import time
import html as html_lib
from operator import itemgetter
from collections import defaultdict
from aqt import mw, gui_hooks, dialogs
from aqt.qt import *
from aqt.utils import getFile, tooltip
from aqt.theme import theme_manager
import base64
# Importa o módulo local de HTML e os arquivos de idioma
from . import html as report_html
from . import portugues, ingles
ADDON_DIR = os.path.dirname(__file__)
ADDON_FOLDER_NAME = os.path.basename(ADDON_DIR)
CONFIG_FILE = os.path.join(ADDON_DIR, "pinned_config.json")
# Lista padrão de ordem das colunas
DEFAULT_COL_ORDER = [
"show_time", "show_avg_time", "show_speed", "show_goal",
"show_retention", "show_ease", "show_leeches",
"show_tomorrow", "show_total", "show_streak_count", "show_non_streak", "show_streak_pct"
]
DEFAULT_CONFIG = {
"pinned_ids": [],
"expanded_ids": [],
"child_sort_order": {},
"deck_goals": {},
"deck_colors": {},
"deck_covers": {},
"col_widths": {
"show_non_streak": 60, # <--- Garante que comece fina (60px)
"show_streak_count": 60,
"show_streak_pct": 45
},
"column_order": DEFAULT_COL_ORDER,
"backup_visibility": {},
"table_width": 98,
"table_max_height": 400,
"is_collapsed": False,
"hide_original_list": False,
"is_grid_view": False,
"streak_threshold": 20,
"leech_threshold": 10,
"chart_days": 7,
"show_charts": True,
"language": "pt",
"stats_history": {},
# --- CONFIGURAÇÃO DE VISIBILIDADE PADRÃO ---
"show_progress": True,
"show_retention": True,
"show_ease": True,
"show_total": True,
"show_streak_count": True,
"show_streak_pct": True,
# Colunas desativadas por padrão
"show_time": False,
"show_avg_time": False,
"show_speed": False,
"show_leeches": False,
"show_tomorrow": False,
"show_goal": False,
"show_non_streak": False,
"last_sort_col": None,
"last_sort_desc": True
}
STATS_CACHE = {}
RPG_CACHE = {}
LANG = {}
SELECTED_FOR_STUDY = set()
TEMP_DECK_NAME = "Estudo Personalizado (Temporário)"
def load_language():
global LANG
cfg = load_config()
lang_code = cfg.get("language", "pt")
if lang_code == "en":
LANG = ingles.t
else:
LANG = portugues.t
def load_config():
if not os.path.exists(CONFIG_FILE):
save_config(DEFAULT_CONFIG)
return DEFAULT_CONFIG.copy()
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
conf = json.load(f)
# 1. Garante chaves principais faltantes
for k, v in DEFAULT_CONFIG.items():
if k not in conf:
conf[k] = v
# 2. CORREÇÃO: Garante que larguras de colunas novas (como não streak) sejam mescladas
# Se o usuário já tem col_widths, adicionamos as que faltam do padrão
if "col_widths" in conf and isinstance(conf["col_widths"], dict):
default_widths = DEFAULT_CONFIG["col_widths"]
for col_key, col_val in default_widths.items():
if col_key not in conf["col_widths"]:
conf["col_widths"][col_key] = col_val
# 3. Lógica de migração da ordem das colunas
current_order = conf.get("column_order", [])
missing = [c for c in DEFAULT_COL_ORDER if c not in current_order]
if missing:
if "show_non_streak" in missing and "show_streak_count" in current_order:
idx = current_order.index("show_streak_count")
current_order.insert(idx + 1, "show_non_streak")
missing.remove("show_non_streak")
conf["column_order"] = current_order + missing
return conf
except:
return DEFAULT_CONFIG.copy()
def save_config(data):
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4, ensure_ascii=False)
except Exception as e:
print("Erro ao salvar config:", e)
def toggle_setting(key):
c = load_config()
c[key] = not c.get(key, True)
save_config(c)
mw.deckBrowser.refresh()
def clear_stats_cache():
global STATS_CACHE, RPG_CACHE
STATS_CACHE = {}
RPG_CACHE = {}
def image_to_base64(filename):
filepath = os.path.join(ADDON_DIR, filename)
if not os.path.exists(filepath):
return ""
ext = filename.split('.')[-1].lower()
mime_type = f"image/{'jpeg' if ext == 'jpg' else ext}"
with open(filepath, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
return f"data:{mime_type};base64,{encoded_string}"
# ==================== LÓGICA DE DADOS E TEMPO ====================
def format_time_str(total_seconds):
if total_seconds <= 0: return "-"
if total_seconds < 60: return f"{int(total_seconds)}s"
elif total_seconds < 3600: return f"{int(total_seconds / 60)}m"
else:
hours = int(total_seconds / 3600)
minutes = int((total_seconds % 3600) / 60)
if minutes > 0: return f"{hours}h {minutes}m"
return f"{hours}h"
def get_recursive_time_seconds(node):
my_seconds = (node.new_count * 15) + (node.learn_count * 10) + (node.review_count * 8)
children_seconds = 0
for child in node.children:
children_seconds += get_recursive_time_seconds(child)
return max(my_seconds, children_seconds)
def get_daily_stats():
start_timestamp = (mw.col.sched.day_cutoff - 86400) * 1000
rows = mw.col.db.all(f"""
SELECT ease, count()
FROM revlog
WHERE id > {start_timestamp}
GROUP BY ease
""")
stats = {1: 0, 2: 0, 3: 0, 4: 0}
for ease, count in rows:
if ease in stats:
stats[ease] = count
return stats
def get_last_review_time():
try:
last_ms = mw.col.db.scalar("SELECT id FROM revlog ORDER BY id DESC LIMIT 1")
if last_ms:
dt = datetime.datetime.fromtimestamp(last_ms / 1000.0)
return dt.strftime("%H:%M:%S")
except:
pass
return "--:--:--"
def get_global_streak():
try:
cutoff = mw.col.sched.day_cutoff
query = f"""
SELECT DISTINCT cast((id/1000 - {cutoff}) / 86400 as int) as day_num
FROM revlog
ORDER BY day_num DESC
"""
days = mw.col.db.list(query)
if not days: return 0
streak = 0
current_check = 0
if 0 in days: current_check = 0
elif -1 in days: current_check = -1
else: return 0
while current_check in days:
streak += 1
current_check -= 1
return streak
except:
return 0
def get_historical_stars(ids_str, goal):
if goal <= 0 or not ids_str: return 0
cutoff = mw.col.sched.day_cutoff
query = f"""
SELECT count()
FROM revlog
WHERE cid IN (SELECT id FROM cards WHERE did IN ({ids_str}))
GROUP BY cast((id / 1000 - {cutoff}) / 86400 as int)
"""
try:
day_counts = mw.col.db.list(query)
total_stars = sum(count // goal for count in day_counts)
return total_stars
except:
return 0
def get_rpg_icon(mature_count, total_cards):
if total_cards == 0:
return "🌱", f"{LANG.get('rpg_icon_level_0', 'Novato')} (0%)"
pct = (mature_count / total_cards) * 100
if pct <= 10: return "🌱", f"{LANG.get('rpg_icon_level_0', 'Novato')} ({int(pct)}%)"
elif pct <= 20: return "🌿", f"{LANG.get('rpg_icon_level_1', 'Iniciante')} ({int(pct)}%)"
elif pct <= 30: return "🍃", f"{LANG.get('rpg_icon_level_2', 'Praticante')} ({int(pct)}%)"
elif pct <= 40: return "🌳", f"{LANG.get('rpg_icon_level_3', 'Estudante')} ({int(pct)}%)"
elif pct <= 50: return "🌲", f"{LANG.get('rpg_icon_level_4', 'Dedicado')} ({int(pct)}%)"
elif pct <= 60: return "🌴", f"{LANG.get('rpg_icon_level_5', 'Experiente')} ({int(pct)}%)"
elif pct <= 70: return "🌸", f"{LANG.get('rpg_icon_level_6', 'Proficiente')} ({int(pct)}%)"
elif pct <= 80: return "🌻", f"{LANG.get('rpg_icon_level_7', 'Especialista')} ({int(pct)}%)"
elif pct <= 90: return "💎", f"{LANG.get('rpg_icon_level_8', 'Mestre')} ({int(pct)}%)"
else: return "👑", f"{LANG.get('rpg_icon_level_9', 'Lenda')} ({int(pct)}%)"
# ==================== LÓGICA RPG ====================
def _calculate_xp_from_reviews(reviews, leech_thr):
"""Helper function to calculate XP from a list of review rows."""
xp = 0
streak = 0
fail_streak = 0
passed_reviews = 0
prev_time_cache = {}
for rid, cid, ease, time_ms, factor, lapses, ivl, reps in reviews:
if factor >= 2500: base_xp = 1
else: base_xp = int((2600 - factor) / 50)
if ease == 1:
xp -= (base_xp * 2)
streak = 0
fail_streak += 1
else:
passed_reviews += 1
fail_streak = 0
streak += 1
current_xp_gain = base_xp
if (reps > 10 and factor > 1900) or (ivl > 100): current_xp_gain = 0
else:
if lapses >= leech_thr: current_xp_gain += 15
if current_xp_gain > 0:
if streak >= 10: current_xp_gain *= 2.0
elif streak >= 5: current_xp_gain *= 1.5
if cid not in prev_time_cache:
prev_time_ms = mw.col.db.scalar(f"SELECT time FROM revlog WHERE cid = {cid} AND id < {rid} ORDER BY id DESC LIMIT 1")
prev_time_cache[cid] = prev_time_ms
else:
prev_time_ms = prev_time_cache[cid]
if prev_time_ms:
diff = time_ms - prev_time_ms
if diff < -500: current_xp_gain += 2
elif diff > 500:
current_xp_gain -= 2
if current_xp_gain < 0: current_xp_gain = 0
xp += int(current_xp_gain)
prev_time_cache[cid] = time_ms
total_reviews = len(reviews)
if total_reviews > 5:
retention = passed_reviews / total_reviews
if retention >= 0.95: xp += 50
elif retention < 0.80: xp -= 50
return int(xp)
def get_rpg_daily_stats(did):
cfg = load_config()
leech_thr = cfg.get("leech_threshold", 10)
cutoff = mw.col.sched.day_cutoff
cache_key = (did, cutoff, leech_thr, "rpg_time_v5")
if cache_key in RPG_CACHE: return RPG_CACHE[cache_key]
try:
start_timestamp = (cutoff - 86400) * 1000
query = f"""
SELECT revlog.id, revlog.cid, revlog.ease, revlog.time, cards.factor, cards.lapses, cards.ivl, cards.reps
FROM revlog
JOIN cards ON revlog.cid = cards.id
WHERE revlog.id > {start_timestamp}
AND cards.did = {did}
ORDER BY revlog.id ASC
"""
rows = mw.col.db.all(query)
hp = 100
fail_streak = 0
for rid, cid, ease, time_ms, factor, lapses, ivl, reps in rows:
damage = 15 + int((2600 - factor) / 100)
if ease == 1:
hp -= damage
fail_streak += 1
if fail_streak >= 3: hp -= 25
else:
fail_streak = 0
heal = 1
if ease >= 3: heal = 2
hp = min(100, hp + heal)
xp = _calculate_xp_from_reviews(rows, leech_thr)
hp = max(0, hp)
children = mw.col.decks.children(did)
children_xp_sum = 0
min_child_hp = 100
has_children = False
for name, child_id in children:
has_children = True
c_hp, c_xp, c_hp_pct = get_rpg_daily_stats(child_id)
children_xp_sum += c_xp
if c_hp < min_child_hp: min_child_hp = c_hp
final_xp = int(xp + children_xp_sum)
final_hp = hp
if not rows and has_children: final_hp = min_child_hp
result = (final_hp, final_xp, final_hp)
RPG_CACHE[cache_key] = result
return result
except Exception as e:
return (100, 0, 100)
def get_global_rpg_level(total_xp):
levels = [
(0, LANG.get("level_0_name", "Aldeão"), "#a0a0a0"),
(100, LANG.get("level_1_name", "Recruta"), "#cd7f32"),
(300, LANG.get("level_2_name", "Soldado"), "#c0c0c0"),
(600, LANG.get("level_3_name", "Veterano"), "#ffd700"),
(1000, LANG.get("level_4_name", "Elite"), "#00ced1"),
(1500, LANG.get("level_5_name", "Mestre"), "#9932cc"),
(2500, LANG.get("level_6_name", "Grão-Mestre"), "#ff4500"),
(4000, LANG.get("level_7_name", "LENDA"), "#ff00ff")
]
if total_xp < 0: return LANG.get("level_cursed", "Amaldiçoado"), "#555", 0, 0, 0, 100
if total_xp >= 4000: return LANG.get("level_7_name", "LENDA"), "#ff00ff", 1.0, 1.0, total_xp, "∞"
current_idx = 0
for i, (threshold, _, _) in enumerate(levels):
if total_xp >= threshold: current_idx = i
else: break
floor, title, color = levels[current_idx]
ceiling = levels[current_idx + 1][0]
xp_needed_for_level = ceiling - floor
xp_progress_in_level = total_xp - floor
pct_level = xp_progress_in_level / xp_needed_for_level
pct_global = total_xp / 4000.0
return title, color, pct_level, pct_global, xp_progress_in_level, xp_needed_for_level
def get_global_daily_summary(days, dids=None):
"""Calculates cards reviewed and XP gained for each of the last N days."""
cfg = load_config()
leech_thr = cfg.get("leech_threshold", 10)
cutoff = mw.col.sched.day_cutoff
start_timestamp = (cutoff - (days * 86400)) * 1000
did_filter = ""
if dids:
dids_str = ",".join(map(str, dids))
if dids_str:
did_filter = f"AND cards.did IN ({dids_str})"
try:
query = f"""
SELECT
revlog.id, revlog.cid, revlog.ease, revlog.time,
cards.factor, cards.lapses, cards.ivl, cards.reps,
cast((revlog.id/1000 - {cutoff}) / 86400 as int) as day_offset
FROM revlog
JOIN cards ON revlog.cid = cards.id
WHERE revlog.id > {start_timestamp} {did_filter}
ORDER BY revlog.id ASC
"""
rows = mw.col.db.all(query)
reviews_by_day = defaultdict(list)
for r in rows:
day_offset = r[-1]
reviews_by_day[day_offset].append(r[:-1])
results = []
for i in range(days - 1, -1, -1):
day_offset = -i
ts = cutoff + (day_offset * 86400) - 43200
date_obj = datetime.datetime.fromtimestamp(ts)
date_key = date_obj.strftime("%d/%m")
day_reviews = reviews_by_day.get(day_offset, [])
cards_count = len(day_reviews)
xp_gained = _calculate_xp_from_reviews(day_reviews, leech_thr) if day_reviews else 0
results.append((date_key, cards_count, xp_gained))
return results
except Exception as e:
print(f"Error in get_global_daily_summary: {e}")
return []
# ==================== SESSÃO DE ESTUDO PERSONALIZADA ====================
def start_custom_study_session():
global SELECTED_FOR_STUDY
if not SELECTED_FOR_STUDY:
tooltip(LANG.get("no_decks_selected", "Nenhum baralho selecionado."))
return
# Se for apenas um deck, seleciona e estuda direto
if len(SELECTED_FOR_STUDY) == 1:
did = list(SELECTED_FOR_STUDY)[0]
mw.col.decks.select(did)
# CORREÇÃO DO CRASH: Inicializa o timer do Anki
if not hasattr(mw.col, "_startTime"):
mw.col.startTimebox()
mw.moveToState("review")
SELECTED_FOR_STUDY.clear()
return
# Se forem vários, cria o deck temporário
old_did = mw.col.decks.id_for_name(TEMP_DECK_NAME)
if old_did:
mw.col.decks.remove([old_did])
tree = mw.col.sched.deck_due_tree()
cids_to_study = set()
for did in SELECTED_FOR_STUDY:
node = find_node(tree, did)
if not node: continue
deck_name = mw.col.decks.name(did)
# Coleta os cards
cids_to_study.update(mw.col.find_cards(f'deck:"{deck_name}" is:due'))
cids_to_study.update(mw.col.find_cards(f'deck:"{deck_name}" is:learn'))
limit_new = node.new_count
if limit_new > 0:
ids_new = mw.col.find_cards(f'deck:"{deck_name}" is:new', order="c.due asc")
cids_to_study.update(ids_new[:limit_new])
if not cids_to_study:
tooltip(LANG.get("no_decks_selected", "Nada para estudar."))
return
# Cria o deck filtrado
did = mw.col.decks.new_filtered(TEMP_DECK_NAME)
deck = mw.col.decks.get(did)
search_query = f"cid:{','.join(map(str, cids_to_study))}"
deck['terms'] = [[search_query, 9999, 0]]
deck['resched'] = True
mw.col.decks.save(deck)
mw.col.sched.rebuild_filtered_deck(did)
mw.col.decks.set_current(did)
# CORREÇÃO DO CRASH: Inicializa o timer antes de entrar na revisão
if not hasattr(mw.col, "_startTime"):
mw.col.startTimebox()
SELECTED_FOR_STUDY.clear() # Limpa a seleção após iniciar
mw.moveToState("review")
# ==================== ESTATÍSTICAS AVANÇADAS E GRÁFICOS ====================
def get_history_data(did, streak_threshold, current_vals, mode='retention'):
cfg = load_config()
history = cfg.get("stats_history", {}).get(str(did), {})
cutoff = mw.col.sched.day_cutoff
days_limit = cfg.get("chart_days", 7)
if days_limit < 3: days_limit = 3
sql_data_map = {}
try:
deck_ids = mw.col.decks.deck_and_child_ids(did)
if deck_ids:
ids_str = ",".join(str(i) for i in deck_ids)
query = f"""
WITH RankedReviews AS (
SELECT
id, cid, ease, type,
cast((id/1000 - {cutoff}) / 86400 as int) as day_offset,
ROW_NUMBER() OVER (PARTITION BY cid ORDER BY id) as rep_count
FROM revlog
WHERE cid IN (SELECT id FROM cards WHERE did IN ({ids_str}))
)
SELECT
day_offset,
sum(case when ease > 1 then 1 else 0 end) as passed,
count() as total,
avg(case when ease > 0 then (select factor from cards where id = RankedReviews.cid) else 0 end) as avg_ease,
sum(case when type=0 then 1 else 0 end) as cnt_new,
sum(case when type=2 then 1 else 0 end) as cnt_lrn,
sum(case when type=1 then 1 else 0 end) as cnt_rev,
sum(case when rep_count >= {streak_threshold} AND type=1 then 1 else 0 end) as cnt_streak_attempt,
sum(case when rep_count >= {streak_threshold} AND type=1 AND ease > 1 then 1 else 0 end) as cnt_streak_success
FROM RankedReviews
GROUP BY day_offset
ORDER BY day_offset DESC
LIMIT {days_limit}
"""
rows = mw.col.db.all(query)
for r in rows:
ts = cutoff + (r[0] * 86400) - 43200
d_str = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
sql_data_map[d_str] = r
except: pass
data_points = []
for i in range(days_limit - 1, -1, -1):
target_ts = cutoff - ((i + 1) * 86400) + 43200
date_obj = datetime.datetime.fromtimestamp(target_ts)
date_key = date_obj.strftime("%Y-%m-%d")
display_date = date_obj.strftime("%d/%m")
val = 0
if mode in ['ease', 'retention']:
if date_key in history:
h = history[date_key]
if mode == 'retention': val = h.get('retention', 0)
elif mode == 'ease': val = h.get('ease', 0)
if val == 0 and date_key in sql_data_map:
r = sql_data_map[date_key]
if mode == 'retention':
total = r[2]
passed = r[1]
val = round((passed / total * 100)) if total > 0 else 0
elif mode == 'ease':
avg_e = r[3]
val = int(avg_e / 10) if avg_e else 0
if val == 0:
if mode == 'ease': val = current_vals.get('ease', 0)
elif mode == 'retention': val = current_vals.get('retention', 0)
else:
if date_key in sql_data_map:
r = sql_data_map[date_key]
if mode == 'reviews':
val = (r[4], r[5], r[6])
elif mode == 'streak_qty':
val = r[8]
elif mode == 'streak_pct':
streak_attempt = r[7]
streak_success = r[8]
val = round((streak_success / streak_attempt * 100)) if streak_attempt > 0 else 0
else:
if mode == 'reviews': val = (0,0,0)
else: val = 0
data_points.append((display_date, val))
return data_points
def generate_svg(data, title, color_line="#4da6ff", chart_type="line"):
if not data: return ""
calculated_width = (len(data) * 40) + 80
width = max(260, calculated_width)
height = 180
margin_top = 50
margin_bottom = 60
margin_left = 35
margin_right = 35
graph_h = height - margin_top - margin_bottom
graph_w = width - margin_left - margin_right
if chart_type == "grouped_bar":
all_vals = []
for d, v in data:
all_vals.extend(v)
max_val = max(all_vals) if all_vals else 10
min_val = 0
else:
vals = [v for d, v in data]
max_val = max(vals) if vals else 100
min_val = min(vals) if vals else 0
if chart_type == "line":
display_min = max(0, min_val - 10)
display_max = 100
if max_val > 100: display_max = max_val * 1.05
val_range = display_max - display_min
if val_range == 0: val_range = 10
else:
display_min = 0
display_max = max_val * 1.1
val_range = display_max
if val_range == 0: val_range = 1
points = []
step_x = graph_w / (len(data) - 1) if len(data) > 1 else graph_w / 2
svg_content = []
svg_content.append(f'<text x="{width/2}" y="20" font-family="sans-serif" font-size="12" font-weight="bold" fill="#555" text-anchor="middle">{title}</text>')
svg_content.append(f'<rect x="{margin_left}" y="{margin_top}" width="{graph_w}" height="{graph_h}" fill="rgba(0,0,0,0.03)" />')
for i, item in enumerate(data):
date_str = item[0]
val = item[1]
x = margin_left + (i * step_x) if len(data) > 1 else width/2
svg_content.append(f'<line x1="{x}" y1="{margin_top}" x2="{x}" y2="{height-margin_bottom}" stroke="#000" stroke-opacity="0.1" stroke-width="1" />')
if chart_type == "grouped_bar":
c_new, c_lrn, c_rev = val
alloc_width = (graph_w / len(data)) * 0.8
group_width = min(alloc_width, 50)
bar_width = group_width / 3
x_new = x - bar_width
x_lrn = x
x_rev = x + bar_width
h_new = (c_new / val_range) * graph_h
h_lrn = (c_lrn / val_range) * graph_h
h_rev = (c_rev / val_range) * graph_h
y_base = margin_top + graph_h
if h_new > 0:
svg_content.append(f'<rect x="{x_new - bar_width/2}" y="{y_base - h_new}" width="{bar_width}" height="{h_new}" fill="#4da6ff" opacity="0.9" />')
svg_content.append(f'<text x="{x_new}" y="{y_base - h_new - 2}" font-family="sans-serif" font-size="8" fill="#333" text-anchor="middle">{c_new}</text>')
if h_lrn > 0:
svg_content.append(f'<rect x="{x_lrn - bar_width/2}" y="{y_base - h_lrn}" width="{bar_width}" height="{h_lrn}" fill="#ff5a5a" opacity="0.9" />')
svg_content.append(f'<text x="{x_lrn}" y="{y_base - h_lrn - 2}" font-family="sans-serif" font-size="8" fill="#333" text-anchor="middle">{c_lrn}</text>')
if h_rev > 0:
svg_content.append(f'<rect x="{x_rev - bar_width/2}" y="{y_base - h_rev}" width="{bar_width}" height="{h_rev}" fill="#5aff5a" opacity="0.9" />')
svg_content.append(f'<text x="{x_rev}" y="{y_base - h_rev - 2}" font-family="sans-serif" font-size="8" fill="#333" text-anchor="middle">{c_rev}</text>')
elif chart_type == "bar":
pass
else: # Line
y = margin_top + graph_h - ((val - display_min) / val_range * graph_h)
points.append((x, y))
svg_content.append(f'<text x="{x}" y="{y-8}" font-family="sans-serif" font-size="10" fill="#333" text-anchor="middle">{val}</text>')
svg_content.append(f'''
<text transform="translate({x+3}, {height-10}) rotate(-90)"
font-family="sans-serif" font-size="10" fill="#777" text-anchor="start">
{date_str}
</text>
''')
if chart_type == "line" and points:
path_d = f"M {points[0][0]} {points[0][1]}"
for px, py in points[1:]:
path_d += f" L {px} {py}"
svg_content.append(f'<path d="{path_d}" fill="none" stroke="{color_line}" stroke-width="2" />')
for px, py in points:
svg_content.append(f'<circle cx="{px}" cy="{py}" r="3" fill="#fff" stroke="{color_line}" stroke-width="2" />')
return f'''
<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg" style="background:rgba(255,255,255,0.95); border-radius:4px; box-shadow:0 2px 5px rgba(0,0,0,0.1);">
{"".join(svg_content)}
</svg>
'''
def generate_global_stats_svg(data):
if not data: return ""
num_days = len(data)
bar_group_width = 65
width = (num_days * bar_group_width) + 80
height = 240
# Margens para comportar labels e valores negativos
margin = {'top': 60, 'bottom': 80, 'left': 40, 'right': 40}
graph_h = height - margin['top'] - margin['bottom']
graph_w = width - margin['left'] - margin['right']
cards_vals = [d[1] for d in data]
xp_vals = [d[2] for d in data]
# BUGFIX: Considera valores absolutos para que a escala suporte barras negativas
max_cards = max(cards_vals) if cards_vals else 1
max_xp = max([abs(x) for x in xp_vals]) if xp_vals else 1
max_val = max(max_cards, max_xp)
if max_val == 0: max_val = 1
svg_content = []
title = LANG.get("daily_summary_chart_title", "Resumo Diário")
svg_content.append(f'<text x="{width/2}" y="20" class="svg-title" text-anchor="middle">{title}</text>')
# Legenda
svg_content.append(f'<rect x="{width/2 - 70}" y="28" width="10" height="10" fill="#4da6ff" />')
svg_content.append(f'<text x="{width/2 - 55}" y="37" class="svg-legend">{LANG.get("cards", "Cards")}</text>')
svg_content.append(f'<rect x="{width/2 + 20}" y="28" width="10" height="10" fill="#ffd700" />')
svg_content.append(f'<text x="{width/2 + 35}" y="37" class="svg-legend">XP</text>')
step_x = graph_w / num_days
bar_width = step_x * 0.35
baseline = margin['top'] + graph_h # Linha de base onde as barras "nascem"
for i, (date_str, cards, xp, level_name) in enumerate(data):
x_base = margin['left'] + (i * step_x) + (step_x / 2)
# Barra de Cards (sempre positiva)
h_cards = (cards / max_val) * graph_h if cards > 0 else 0
y_cards = baseline - h_cards
svg_content.append(f'<rect x="{x_base - bar_width - 1}" y="{y_cards}" width="{bar_width}" height="{h_cards}" fill="#4da6ff"><title>{LANG.get("cards", "Cards")}: {cards}</title></rect>')
if cards > 0:
svg_content.append(f'<text x="{x_base - bar_width/2 - 1}" y="{y_cards - 3}" class="svg-bar-label" text-anchor="middle">{cards}</text>')
# Barra de XP (Suporta valores negativos)
h_xp = (abs(xp) / max_val) * graph_h
if xp >= 0:
y_xp = baseline - h_xp
label_y = y_xp - 3
else:
# Se negativo, a barra começa no baseline e desce
y_xp = baseline
label_y = baseline + h_xp + 10 # Texto abaixo da barra negativa
svg_content.append(f'<rect x="{x_base + 1}" y="{y_xp}" width="{bar_width}" height="{h_xp}" fill="#ffd700"><title>XP: {xp}</title></rect>')
if xp != 0:
svg_content.append(f'<text x="{x_base + bar_width/2 + 1}" y="{label_y}" class="svg-bar-label" text-anchor="middle">{xp}</text>')
# Labels do Eixo X
label_y_axis = height - margin["bottom"] + 15
svg_content.append(f'''
<text transform="translate({x_base - 5}, {label_y_axis}) rotate(-60)"
class="svg-axis-label" text-anchor="end">{date_str}</text>
''')
svg_content.append(f'''
<text transform="translate({x_base - 5}, {label_y_axis + 15}) rotate(-60)"
class="svg-level-label" text-anchor="end">{level_name}</text>
''')
return f'''
<div style="max-width: 600px; overflow-x: auto; padding-bottom: 10px; background: rgba(255,255,255,0.95); border-radius: 4px;">
<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">
<style>
.svg-title {{ font-family: sans-serif; font-size: 14px; font-weight: bold; fill: #333; }}
.svg-legend {{ font-family: sans-serif; font-size: 10px; fill: #555; }}
.svg-bar-label {{ font-family: sans-serif; font-size: 9px; fill: #333; }}
.svg-axis-label {{ font-family: sans-serif; font-size: 10px; fill: #777; }}
.svg-level-label {{ font-family: sans-serif; font-size: 9px; font-weight: bold; fill: #333; }}
</style>
{"".join(svg_content)}
</svg>
</div>
'''
def get_deck_stats_advanced(did, streak_threshold, leech_threshold, deck_goal):
cutoff = mw.col.sched.day_cutoff
cfg = load_config()
chart_days = cfg.get("chart_days", 7)
show_charts = cfg.get("show_charts", True)
# Cache key atualizada para incluir o novo retorno
cache_key = (did, streak_threshold, leech_threshold, deck_goal, cutoff, chart_days, show_charts, "v_cid_fix_v2")
if cache_key in STATS_CACHE: return STATS_CACHE[cache_key]
try:
deck_ids = mw.col.decks.deck_and_child_ids(did)
if not deck_ids:
return ("-", "-", 0, 0, 0, "-", "-", 0, 0, "-", 0, 0, 0, {1:0, 2:0, 3:0, 4:0}, "-", "", "", "", "", "", "", "")
ids_str = ",".join(str(i) for i in deck_ids)
# CORREÇÃO: Busca todos os IDs para cálculo preciso de Non-Streak
all_cids = mw.col.db.list(f"SELECT id FROM cards WHERE did IN ({ids_str})")
total_cards = len(all_cids)
# BUSCA DOS IDs DOS CARTÕES COM STREAK (Garante sincronia com o Browser)
mature_cids = mw.col.db.list(f"""
SELECT c.id FROM cards c
WHERE c.did IN ({ids_str})
AND c.reps >= {streak_threshold}
AND (
SELECT count(*)
FROM revlog r
WHERE r.cid = c.id
AND r.id > COALESCE((
SELECT MAX(id)
FROM revlog r2
WHERE r2.cid = c.id AND r2.ease = 1
), 0)
AND r.ease > 1
) >= {streak_threshold}
""")
mature_count_int = len(mature_cids)
mature_cids_str = ",".join(map(str, mature_cids))
# CÁLCULO PRECISO DOS IDs NÃO STREAK
non_streak_cids = list(set(all_cids) - set(mature_cids))
non_streak_cids_str = ",".join(map(str, non_streak_cids))
pct_mature = (mature_count_int / total_cards * 100) if total_cards > 0 else 0
maturity_str = f"{mature_count_int}"
maturity_pct_str = f"{pct_mature:.0f}%"
start_timestamp = (cutoff - 86400) * 1000
today_reviews = mw.col.db.all(f"""
SELECT revlog.ease, revlog.time, cards.reps, revlog.type
FROM revlog
JOIN cards ON revlog.cid = cards.id
WHERE revlog.id > {start_timestamp}
AND cards.did IN ({ids_str})
""")
retention_str = "-"
done_today_count = 0
passed_today_count = 0
speed_str = "-"
avg_time_str = "-"
total_time_ms = 0
ease_counts = {1: 0, 2: 0, 3: 0, 4: 0}
current_retention_val = 0
today_streak_qty = 0
today_streak_attempts = 0
if today_reviews:
done_today_count = len(today_reviews)
for ease, time, reps, type in today_reviews:
if ease > 1: passed_today_count += 1
total_time_ms += time
if ease in ease_counts: ease_counts[ease] += 1
if type == 1 and reps >= streak_threshold:
today_streak_attempts += 1
if ease > 1: today_streak_qty += 1
current_retention_val = round(passed_today_count / done_today_count * 100)
retention_str = f"{current_retention_val}%"
today_streak_pct = 0
if today_streak_attempts > 0:
today_streak_pct = round(today_streak_qty / today_streak_attempts * 100)
if done_today_count > 0:
total_time_min = total_time_ms / 60000
if total_time_min > 0:
cpm = done_today_count / total_time_min
speed_str = f"{cpm:.1f}"
avg_sec = (total_time_ms / 1000) / done_today_count
avg_time_str = f"{avg_sec:.1f}s"
else:
history_times = mw.col.db.list(f"""
SELECT revlog.time
FROM revlog
JOIN cards ON revlog.cid = cards.id
WHERE cards.did IN ({ids_str})
ORDER BY revlog.id DESC LIMIT 100
""")
if history_times:
hist_count = len(history_times)
hist_total_ms = sum(history_times)
hist_avg_sec = (hist_total_ms / 1000) / hist_count
avg_time_str = f"{hist_avg_sec:.1f}s"
hist_total_min = hist_total_ms / 60000
if hist_total_min > 0:
hist_cpm = hist_count / hist_total_min
speed_str = f"{hist_cpm:.1f}"
tomorrow_due_date = mw.col.sched.today + 1
tomorrow_count = mw.col.db.scalar(f"""
SELECT count() FROM cards
WHERE did IN ({ids_str})
AND queue = 2
AND due = {tomorrow_due_date}
""")
avg_ease = mw.col.db.scalar(f"SELECT avg(factor) FROM cards WHERE did IN ({ids_str}) AND queue != 0")
ease_str = "-"
current_ease_val = 0
if avg_ease:
current_ease_val = int(avg_ease / 10)
ease_str = f"{current_ease_val}%"
leech_count = mw.col.db.scalar(f"SELECT count() FROM cards WHERE did IN ({ids_str}) AND lapses >= {leech_threshold}")
total_stars = get_historical_stars(ids_str, deck_goal)
if "stats_history" not in cfg: cfg["stats_history"] = {}
today_key = datetime.datetime.fromtimestamp(cutoff - 43200).strftime('%Y-%m-%d')
did_str = str(did)
if did_str not in cfg["stats_history"]: cfg["stats_history"][did_str] = {}
saved_day = cfg["stats_history"][did_str].get(today_key, {})
if saved_day.get("ease") != current_ease_val or saved_day.get("retention") != current_retention_val:
cfg["stats_history"][did_str][today_key] = {"ease": current_ease_val, "retention": current_retention_val}
save_config(cfg)
retention_svg = reviews_svg = ease_svg = streak_qty_svg = streak_pct_svg = ""
if show_charts:
current_vals = {'ease': current_ease_val, 'retention': current_retention_val, 'streak_qty': today_streak_qty, 'streak_pct': today_streak_pct}
ret_data = get_history_data(did, streak_threshold, current_vals, 'retention')
rev_data = get_history_data(did, streak_threshold, current_vals, 'reviews')
ease_data = get_history_data(did, streak_threshold, current_vals, 'ease')
retention_svg = generate_svg(ret_data, LANG.get("chart_title_retention", "Retenção"), "#4da6ff", "line")
reviews_svg = generate_svg(rev_data, LANG.get("chart_title_reviews", "Revisões"), "", "grouped_bar")
ease_svg = generate_svg(ease_data, LANG.get("chart_title_ease", "Ease Médio"), "#FFD700", "line")
result = (maturity_str, retention_str, total_cards, tomorrow_count, done_today_count, speed_str, ease_str, leech_count, mature_count_int, avg_time_str, total_time_ms, total_stars, passed_today_count, ease_counts, maturity_pct_str, retention_svg, reviews_svg, ease_svg, streak_qty_svg, streak_pct_svg, mature_cids_str, non_streak_cids_str)
STATS_CACHE[cache_key] = result
return result
except Exception as e:
return ("-", "-", 0, 0, 0, "-", "-", 0, 0, "-", 0, 0, 0, {1:0, 2:0, 3:0, 4:0}, "-", "", "", "", "", "", "", "")
# ==================== LÓGICA DE ORDENAÇÃO ====================
def sort_pinned_decks(col_name):
cfg = load_config()
pinned = cfg.get("pinned_ids", [])
if not pinned: return
if cfg.get("last_sort_col") == col_name:
desc = not cfg.get("last_sort_desc", True)
else:
desc = True
cfg["last_sort_col"] = col_name
cfg["last_sort_desc"] = desc
sort_data = []
streak_thr = cfg.get("streak_threshold", 20)
leech_thr = cfg.get("leech_threshold", 10)
tree = mw.col.sched.deck_due_tree()
for did in pinned:
node = find_node(tree, did)
deck_name = node.name if node else mw.col.decks.name(did)
val = 0
if col_name == "col_name":
val = deck_name.lower()
elif col_name == "col_counts":
if node: val = node.new_count + node.learn_count + node.review_count
elif col_name == "show_time":
if node: val = get_recursive_time_seconds(node)
else:
deck_goal = cfg.get("deck_goals", {}).get(str(did), 100)
stats = get_deck_stats_advanced(did, streak_thr, leech_thr, deck_goal)
if col_name == "show_avg_time":
if stats[4] > 0: val = stats[10] / stats[4]
elif col_name == "show_speed":
if stats[10] > 0: val = stats[4] / (stats[10] / 60000)
elif col_name == "show_goal":
val = deck_goal
elif col_name == "show_retention":
if stats[4] > 0: val = stats[12] / stats[4]
elif col_name == "show_ease":
try: val = int(stats[6].replace("%", ""))
except: val = 0
elif col_name == "show_leeches":
val = stats[7]
elif col_name == "show_tomorrow":
val = stats[3]
elif col_name == "show_total":
val = stats[2]
elif col_name == "show_streak_count":
val = stats[8]
elif col_name == "show_streak_pct":
if stats[2] > 0: val = stats[8] / stats[2]
elif col_name == "show_non_streak":
# Total - Streak
val = stats[2] - stats[8]
sort_data.append((did, val, deck_name.lower()))
def sort_key(item):
did, val, name = item
if isinstance(val, str):
return (val, name) if not desc else (val, name)
else:
return (-val, name) if desc else (val, name)
is_string_sort = (col_name == "col_name")
sort_data.sort(key=sort_key, reverse=(desc if is_string_sort else False))
cfg["pinned_ids"] = [item[0] for item in sort_data]
save_config(cfg)
mw.deckBrowser.refresh()
# ==================== MENU ====================
def set_deck_cover(did):
path = getFile(mw, LANG.get("choose_cover_image", "Escolher Imagem"), None, f"{LANG.get('images', 'Imagens')} (*.jpg *.jpeg *.png *.gif *.webp)")
if not path:
return
fname = mw.col.media.add_file(path)
cfg = load_config()
if "deck_covers" not in cfg:
cfg["deck_covers"] = {}
cfg["deck_covers"][str(did)] = fname
save_config(cfg)
mw.deckBrowser.refresh()
tooltip(LANG.get("cover_set_success", "Capa definida!"))
def remove_deck_cover(did):
cfg = load_config()
if "deck_covers" in cfg and str(did) in cfg["deck_covers"]:
del cfg["deck_covers"][str(did)]
save_config(cfg)
mw.deckBrowser.refresh()
tooltip(LANG.get("cover_removed", "Capa removida."))
def on_options_menu(menu, deck_id):
config = load_config()
if deck_id in config.get("pinned_ids", []):
cols_menu = menu.addMenu(LANG.get("customize_columns", "Colunas"))
options = [
("show_progress", LANG.get("col_progress_bar", "Progresso")),
("show_time", LANG.get("col_estimated_time", "Tempo")),
("show_avg_time", LANG.get("col_avg_time", "Média")),
("show_speed", LANG.get("col_speed", "Velocidade")),
("show_goal", LANG.get("col_daily_goal", "Meta")),
("show_retention", LANG.get("col_retention_today", "Retenção")),
("show_ease", LANG.get("col_ease", "Ease")),
("show_leeches", LANG.get("col_leeches", "Sanguessugas")),
("show_tomorrow", LANG.get("col_tomorrow_forecast", "Amanhã")),
("show_total", LANG.get("col_total_cards", "Total")),
("show_streak_count", LANG.get("col_streak_count", "Streak")),
("show_non_streak", LANG.get("non_streak", "Não Streak")), # Nova opção
("show_streak_pct", LANG.get("col_streak_pct", "Streak %"))
]
for key, label in options:
act = cols_menu.addAction(label)
act.setCheckable(True)
act.setChecked(config.get(key, True))
act.triggered.connect(lambda checked, k=key: toggle_setting(k))
cols_menu.addSeparator()
act_all = cols_menu.addAction(LANG.get("show_all_columns", "Mostrar Tudo"))
act_all.triggered.connect(lambda: show_all_columns())
if config.get("backup_visibility"):
act_restore = cols_menu.addAction(LANG.get("restore_previous_view", "Restaurar"))
act_restore.triggered.connect(lambda: restore_columns())
color_menu = menu.addMenu(LANG.get("set_background_color", "Cor de Fundo"))
colors = [
(LANG.get("default", "Padrão"), ""),
(LANG.get("red", "Vermelho"), "rgba(255, 80, 80, 0.15)"),
(LANG.get("green", "Verde"), "rgba(80, 255, 80, 0.15)"),
(LANG.get("blue", "Azul"), "rgba(80, 150, 255, 0.15)"),
(LANG.get("yellow", "Amarelo"), "rgba(255, 255, 80, 0.15)"),
(LANG.get("purple", "Roxo"), "rgba(200, 80, 255, 0.15)")
]
for name, code in colors:
a = color_menu.addAction(name)
a.triggered.connect(lambda _, d=deck_id, c=code: set_deck_color(d, c))
cover_menu = menu.addMenu(LANG.get("set_cover_image", "Capa"))
act_set_cover = cover_menu.addAction(LANG.get("set_image", "Definir"))
act_set_cover.triggered.connect(lambda: set_deck_cover(deck_id))
act_rem_cover = cover_menu.addAction(LANG.get("remove_image", "Remover"))
act_rem_cover.triggered.connect(lambda: remove_deck_cover(deck_id))
menu.addSeparator()
a = menu.addAction(LANG.get("unpin_from_top", "Desafixar"))
a.triggered.connect(lambda: toggle_pin(deck_id, False))
else:
a = menu.addAction(LANG.get("pin_to_top", "Fixar"))
a.triggered.connect(lambda: toggle_pin(deck_id, True))
def toggle_pin(did, pin=True):
cfg = load_config()
ids = cfg["pinned_ids"]
if pin and did not in ids:
ids.insert(0, did)
elif not pin and did in ids:
ids.remove(did)
cfg["pinned_ids"] = ids
save_config(cfg)
mw.deckBrowser.refresh()
def set_deck_color(did, color_code):
cfg = load_config()
if "deck_colors" not in cfg: cfg["deck_colors"] = {}
if color_code:
cfg["deck_colors"][str(did)] = color_code
else:
if str(did) in cfg["deck_colors"]:
del cfg["deck_colors"][str(did)]
save_config(cfg)
mw.deckBrowser.refresh()
def show_all_columns():
c = load_config()
backup = {}
keys = ["show_time", "show_avg_time", "show_speed", "show_goal", "show_retention",
"show_ease", "show_leeches", "show_tomorrow", "show_total", "show_streak_count", "show_non_streak", "show_streak_pct"]
for k in keys:
backup[k] = c.get(k, True)
c["backup_visibility"] = backup
for k in keys:
c[k] = True
save_config(c)
mw.deckBrowser.refresh()
def restore_columns():
c = load_config()
backup = c.get("backup_visibility", {})
if not backup: return
for k, v in backup.items():
c[k] = v
c["backup_visibility"] = {}
save_config(c)
mw.deckBrowser.refresh()
# ==================== RENDERIZAÇÃO (UI) ====================
def escape_for_html(text):
return text.replace('"', '&quot;')
def make_safe_link(text, query, style=""):
q = query.replace("\\", "\\\\")
q = q.replace("'", "\\'")
q = q.replace('"', '&quot;')
return f'<a href="#" onclick="pycmd(\'browser:{q}\');return false;" style="{style}">{text}</a>'
def find_node(tree, target):
if tree.deck_id == target:
return tree
for child in tree.children:
n = find_node(child, target)
if n: return n
return None
def get_visual_counts(node, did):
if not node.children:
return node.new_count, node.learn_count, node.review_count
total_new, total_lrn, total_due = 0, 0, 0
for child in node.children:
n, l, d = get_visual_counts(child, child.deck_id)
total_new += n
total_lrn += l
total_due += d
return max(total_new, node.new_count), max(total_lrn, node.learn_count), max(total_due, node.review_count)
def render_node(node, depth, cfg, is_pinned_root, idx, total_count, col_widths, parent_id=None):
did = node.deck_id
name = node.name.split("::")[-1]
full_name = node.name
full_name_esc = escape_for_html(full_name)
new, lrn, due = get_visual_counts(node, did)
has_kids = len(node.children) > 0
expanded = did in cfg.get("expanded_ids", [])
sym = "[-]" if expanded and has_kids else "[+]" if has_kids else ""
expander = f'<span class="exp" onclick="pycmd(\'exp:{did}\');event.stopPropagation();">{sym}</span>' if has_kids else '<span class="expph"></span>'
streak_thr = cfg.get("streak_threshold", 20)
leech_thr = cfg.get("leech_threshold", 10)
deck_goal = cfg.get("deck_goals", {}).get(str(did), 100)
row_bg = cfg.get("deck_colors", {}).get(str(did), "")
style_bg = f'style="background-color:{row_bg} !important;"' if row_bg else ""
# Descompactando os 22 itens retornados (adicionado non_streak_cids_str)
maturity, retention, total_cards, tomorrow, done_today, speed, ease, leeches, mature_count_int, avg_time, _, total_stars, _, ease_counts, maturity_pct, retention_svg, reviews_svg, ease_svg, streak_qty_svg, streak_pct_svg, mature_cids_str, non_streak_cids_str = get_deck_stats_advanced(did, streak_thr, leech_thr, deck_goal)
hp, xp, hp_pct = get_rpg_daily_stats(did)
hp_color = "#5aff5a" if hp >= 70 else "#ff9d5a" if hp >= 30 else "#ff5a5a"
hp_tooltip = f"{LANG.get('deck_hp', 'HP')}: {hp}/100"
xp_display = f'<span title="{hp_tooltip}" style="cursor:help; font-size:9px; color:{"#FFD700" if xp>=0 else "#ff5a5a"}; margin-left:4px; font-weight:bold;">{"+" if xp>=0 else ""}{xp} XP</span>'
hp_html = f'<div style="width: 100%; height: 6px; background: rgba(0,0,0,0.3); margin-top: 3px; border-radius: 3px; overflow: hidden; cursor: help;" title="{hp_tooltip}"><div style="width: {hp_pct}%; height: 100%; background: {hp_color}; transition: width 0.5s;"></div></div>'
rpg_icon, rpg_title = get_rpg_icon(mature_count_int, total_cards)
name_display = f'<span title="{rpg_title}" style="cursor:help; margin-right:4px;">{rpg_icon}</span>{name}'
stars_html = f'<span title="{LANG.get("total_goals_hit_history", "Total")}: {total_stars}" style="color:#FFD700; font-size:10px; margin-left:2px; font-weight:bold;">⭐{total_stars}</span>' if total_stars > 0 else ""
progress_html = ""
if cfg.get("show_progress", True):
remaining = new + lrn + due
daily_total = done_today + remaining
daily_pct = (done_today / daily_total * 100) if daily_total > 0 else (100 if done_today > 0 else 0)
bar_color = "#FFD700" if (done_today >= deck_goal and deck_goal > 0) else ("#ff5a5a" if daily_pct < 50 else "#4da6ff" if daily_pct < 100 else "#5aff5a")
tooltip_text = LANG.get("deck_progress_tooltip", "{pct}%").format(deck_name=full_name_esc, pct=int(daily_pct), done=done_today, total=daily_total) + f"&#10;🟥 {ease_counts[1]} 🟧 {ease_counts[2]} 🟩 {ease_counts[3]} 🟦 {ease_counts[4]}"
progress_html = f'<div style="width: 100%; height: 6px; background: var(--progress-bg); margin-top: 2px; border-radius: 3px; overflow: hidden; cursor: help;" title="{tooltip_text}"><div style="width: {daily_pct}%; height: 100%; background: {bar_color}; transition: width 0.5s;"></div></div>'
time_str = format_time_str(get_recursive_time_seconds(node)) if cfg.get("show_time", True) else "-"
w_ord = col_widths.get("col_ord", 40)
order_controls = '<td class="ord-col" style="position:relative; width:%dpx;" data-col="col_ord"></td>' % w_ord
if is_pinned_root or parent_id is not None:
parent_arg = f",{parent_id}" if parent_id else ""
up_arrow = f'<a class="arr-btn" onclick="pycmd(\'move_up:{did}{parent_arg}\');return false;">▲</a>' if idx > 0 else '<span class="arr-ph"></span>'
down_arrow = f'<a class="arr-btn" onclick="pycmd(\'move_down:{did}{parent_arg}\');return false;">▼</a>' if idx < total_count - 1 else '<span class="arr-ph"></span>'
order_controls = f'<td class="ord-col" style="position:relative; width:{w_ord}px;" data-col="col_ord"><div class="ord-wrapper">{up_arrow}<input type="number" class="oi" value="{idx+1}" onchange="pycmd(\'ord:{did},{parent_arg},\'+this.value)">{down_arrow}</div><div class="resizer" onmousedown="rsStart(event, \'col_ord\')"></div></td>'
drag_attrs = f'draggable="true" data-did="{did}" ondragstart="pdStart(event)" ondragover="pdOver(event)" ondragleave="pdLeave(event)" ondrop="pdDrop(event)"' if is_pinned_root else ""
select_cell = f'<td class="sel-col" style="position:relative; width:{col_widths.get("col_select", 30)}px;" data-col="col_select"><input type="checkbox" class="study-cb" onclick="pycmd(\'select_deck:{did}\'); event.stopPropagation();" {"checked" if did in SELECTED_FOR_STUDY else ""} title="Selecionar para estudo em grupo"><div class="resizer" onmousedown="rsStart(event, \'col_select\')"></div></td>'
def add_data_cell(key, content, css_class="inf", extra=""):
if not cfg.get(key, True): return ""
# CORREÇÃO: Se a largura for 0 ou não existir, força 60px como fallback
w = col_widths.get(key, 0)
if w == 0 and key == "show_non_streak":
w = 60
style = f'style="position:relative; {"width:"+str(w)+"px;" if w>0 else ""} {extra[7:-1] if extra.startswith("style=") else ""}"'
return f'<td class="{css_class}" {style} {extra if not extra.startswith("style=") else ""} data-col="{key}">{content}<div class="resizer" onmousedown="rsStart(event, \'{key}\')"></div></td>'
# CORREÇÃO DO LINK DE STREAK: Usando cid: com a lista de IDs
streak_link = make_safe_link(maturity, f"cid:{mature_cids_str}") if (mature_count_int > 0 and mature_cids_str) else maturity
# CÁLCULO DO NÃO STREAK (CORRIGIDO PARA USAR CIDs)
non_streak_count = total_cards - mature_count_int
if non_streak_count > 0 and non_streak_cids_str:
# Usa a lista exata de IDs que não estão em streak
non_streak_link = make_safe_link(non_streak_count, f"cid:{non_streak_cids_str}", "color:var(--text-muted)")
else:
non_streak_link = f'<span style="color:var(--text-muted)">{non_streak_count}</span>'
data_map = {
"show_time": (time_str, "inf", f'title="{LANG.get("estimated_time_tooltip", "Tempo")}"'),
"show_avg_time": (avg_time, "inf", f'title="{LANG.get("avg_seconds_per_card", "Média")}"'),
"show_speed": (speed, "inf", f'title="{LANG.get("speed_tooltip", "Velocidade")}"'),
"show_goal": (f'<input type="number" value="{deck_goal}" onchange="pycmd(\'set_goal:{did},\'+this.value)" class="goal-input" title="Meta">{stars_html}', "inf", 'style="white-space:nowrap;"'),
"show_retention": (retention, "inf", f'data-chart="{html_lib.escape(retention_svg)}" onmouseover="showMovingChart(this, event)" onmousemove="moveChart(event)" onmouseout="hideChart()"' if retention_svg else ""),
"show_ease": (ease, "inf", f'data-chart="{html_lib.escape(ease_svg)}" onmouseover="showMovingChart(this, event)" onmousemove="moveChart(event)" onmouseout="hideChart()"' if ease_svg else ""),
"show_leeches": (make_safe_link(leeches, f'deck:"{full_name}" prop:lapses>={leech_thr}', f'color:{"#ff5a5a" if leeches>0 else "var(--text-muted)"}') if leeches>0 else f'<span style="color:var(--text-muted)">{leeches}</span>', "inf", f'title="{LANG.get("leech_tooltip", "Sanguessugas").format(count=leech_thr)}"'),
"show_tomorrow": (make_safe_link(tomorrow, f'deck:"{full_name}" prop:due=1', f'color:{"#ff9999" if tomorrow>50 else "var(--text-muted)"}') if tomorrow>0 else f'<span style="color:var(--text-muted)">{tomorrow}</span>', "inf", f'title="{LANG.get("tomorrow_tooltip", "Amanhã")}"'),
"show_total": (total_cards, "inf", f'title="{LANG.get("total_tooltip", "Total")}"'),
"show_streak_count": (streak_link, "mat", f'title="{LANG.get("streak_count_tooltip", "Streak").format(count=streak_thr)}"'),
"show_non_streak": (non_streak_link, "mat", f'title="{LANG.get("non_streak", "Não Streak")}"'),
"show_streak_pct": (maturity_pct, "mat", f'title="{LANG.get("streak_pct_tooltip", "Streak %")}"')
}
cols_html = "".join(add_data_cell(k, *data_map[k]) for k in cfg.get("column_order", DEFAULT_COL_ORDER) if k in data_map)
html = f'''
<tr class="pr" {drag_attrs} {style_bg}>
{order_controls} {select_cell}
<td class="nm" style="padding-left:{depth*20}px; position:relative; width:{col_widths.get("col_name", 300)}px;" data-col="col_name">
<div style="display:flex; align-items:center; overflow:hidden;">{expander}<a href="#" onclick="pycmd('open:{did}');return false;" title="{full_name_esc}" style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">{name_display}</a>{xp_display}</div>
{hp_html} {progress_html} <div class="resizer" onmousedown="rsStart(event, 'col_name')"></div>
</td>
<td class="st" style="position:relative; width:{col_widths.get("col_counts", 160)}px;" data-col="col_counts" {f'data-chart="{html_lib.escape(reviews_svg)}" onmouseover="showMovingChart(this, event)" onmousemove="moveChart(event)" onmouseout="hideChart()"' if reviews_svg else ""}>
<span class="n{'' if new else ' z'}">{new}</span><span class="l{' z' if not lrn else ''}">{lrn}</span><span class="d{' z' if not due else ''}">{due}</span>
<div class="resizer" onmousedown="rsStart(event, 'col_counts')"></div>
</td>
{cols_html}
<td class="op" style="position:relative; width:{col_widths.get("col_opts", 50)}px;" data-col="col_opts"><a href="#" onclick="pycmd('opts:{did}');return false;">⚙</a><div class="resizer" onmousedown="rsStart(event, 'col_opts')"></div></td>
</tr>'''
if has_kids and expanded:
children = node.children
saved_order = cfg.get("child_sort_order", {}).get(str(did), [])
if saved_order:
order_map = {int(id): i for i, id in enumerate(saved_order)}
children.sort(key=lambda x: order_map.get(x.deck_id, 99999))
for i, c in enumerate(children):
html += render_node(c, depth+1, cfg, False, i, len(children), col_widths, parent_id=did)
return html
def render_grid_node(node, depth, cfg, streak_thr, leech_thr):
did = node.deck_id
name = node.name.split("::")[-1]
full_name = node.name
full_name_esc = escape_for_html(full_name)
new, lrn, due = get_visual_counts(node, did)
has_kids = len(node.children) > 0
expanded = did in cfg.get("expanded_ids", [])
sym = "[-]" if expanded and has_kids else "[+]" if has_kids else ""
expander = f'<span class="grid-exp" onclick="pycmd(\'exp:{did}\');event.stopPropagation();">{sym}</span>' if has_kids else ''
deck_goal = cfg.get("deck_goals", {}).get(str(did), 100)
row_bg = cfg.get("deck_colors", {}).get(str(did), "")
cover_file = cfg.get("deck_covers", {}).get(str(did))
cover_html = ""
overlay_class = ""
text_shadow_style = ""
if cover_file:
cover_html = f'<img src="{cover_file}" class="grid-cover"><div class="grid-overlay"></div>'
text_shadow_style = 'text-shadow: 0 1px 3px rgba(0,0,0,0.9); color: #fff;'
maturity, retention, total_cards, tomorrow, done_today, speed, ease, leeches, mature_count_int, avg_time, _, total_stars, _, ease_counts, maturity_pct, retention_svg, reviews_svg, ease_svg, streak_qty_svg, streak_pct_svg, mature_cids_str, non_streak_cids_str = get_deck_stats_advanced(did, streak_thr, leech_thr, deck_goal)
rpg_icon, rpg_title = get_rpg_icon(mature_count_int, total_cards)
hp, xp, hp_pct = get_rpg_daily_stats(did)
xp_display = f'<span style="font-size:10px; color:#FFD700; font-weight:bold;">+{xp} XP</span>' if xp >= 0 else f'<span style="font-size:10px; color:#ff5a5a; font-weight:bold;">{xp} XP</span>'
is_selected = did in SELECTED_FOR_STUDY
checked_attr = "checked" if is_selected else ""
select_checkbox = f'''
<input type="checkbox" class="study-cb" onclick="pycmd('select_deck:{did}'); event.stopPropagation();" {checked_attr} title="Selecionar para estudo em grupo">
'''
progress_html = ""
if cfg.get("show_progress", True):
remaining = new + lrn + due
daily_total = done_today + remaining
daily_pct = 0
if daily_total > 0: daily_pct = (done_today / daily_total) * 100
elif done_today > 0: daily_pct = 100
is_goal_met = done_today >= deck_goal and deck_goal > 0
if is_goal_met: bar_color = "#FFD700"
elif daily_pct < 50: bar_color = "#ff5a5a"
elif daily_pct < 100: bar_color = "#4da6ff"
else: bar_color = "#5aff5a"
progress_html = f'''
<div class="grid-progress-bar" style="width: 100%; height: 4px; background: rgba(255,255,255,0.3); margin: 4px 0; border-radius: 2px; overflow: hidden; position: relative; z-index: 2;">
<div style="width: {daily_pct}%; height: 100%; background: {bar_color};"></div>
</div>
'''
time_str = format_time_str(get_recursive_time_seconds(node))
leech_style = f'color:{"#ff5a5a" if leeches > 0 else "var(--text-muted)"}'
if cover_file and leeches == 0: leech_style = "color: rgba(255,255,255,0.7);"
if leeches > 0:
query = f'deck:"{full_name}" prop:lapses>={leech_thr}'
leech_content = make_safe_link(leeches, query, leech_style)
else:
leech_content = f'<span style="{leech_style}">{leeches}</span>'
tom_style = f'color:{"#ff9999" if tomorrow > 50 else "var(--text-muted)"}'
if cover_file and tomorrow <= 50: tom_style = "color: rgba(255,255,255,0.7);"
if tomorrow > 0:
query = f'deck:"{full_name}" prop:due=1'
tom_content = make_safe_link(tomorrow, query, tom_style)
else:
tom_content = f'<span style="{tom_style}">{tomorrow}</span>'
if mature_count_int > 0:
query = f'deck:"{full_name}" prop:reps>={streak_thr}'
streak_content = make_safe_link(maturity, query)
else:
streak_content = maturity
# Non-Streak Grid (CORRIGIDO PARA USAR CIDs)
non_streak_count = total_cards - mature_count_int
if non_streak_count > 0 and non_streak_cids_str:
non_streak_content = make_safe_link(non_streak_count, f"cid:{non_streak_cids_str}")
else:
non_streak_content = non_streak_count
tooltip_streak = LANG.get("mature_cards_count", "Streak").format(count=streak_thr)
tooltip_leech = LANG.get("leeches_tooltip_long", "Sanguessugas").format(count=leech_thr)
tooltip_streak_pct = LANG.get("mature_cards_pct", "Streak %")
tooltip_non_streak = LANG.get("non_streak", "Não Streak")
data_map = {
"show_time": ("⏱️", time_str, LANG.get("estimated_time_tooltip", "Tempo")),
"show_avg_time": ("s/card", avg_time, LANG.get("avg_seconds_per_card", "Média")),
"show_speed": ("🚀", speed, LANG.get("speed_tooltip", "Velocidade")),
"show_goal": ("🎯", f"{deck_goal}", LANG.get("daily_goal_tooltip", "Meta")),
"show_retention": ("% Hj", retention, LANG.get("retention_rate_today", "Retenção")),
"show_ease": ("⚖️", ease, LANG.get("avg_ease_tooltip", "Ease")),
"show_leeches": ("🩸", leech_content, tooltip_leech),
"show_tomorrow": ("🔮", tom_content, LANG.get("tomorrow_tooltip", "Amanhã")),
"show_total": (LANG.get("total_tooltip", "Total"), total_cards, LANG.get("total_cards_in_deck", "Total")),
"show_streak_count": (maturity, "mat", f'title="{tooltip_streak}"'),
"show_non_streak": (non_streak_content, "mat", f'title="{tooltip_non_streak}"'),
"show_streak_pct": (maturity_pct, "mat", f'title="{tooltip_streak_pct}"')
}
if mature_count_int > 0:
data_map["show_streak_count"] = (streak_content, "mat", f'title="{tooltip_streak}"')
grid_rows = ""
col_order = cfg.get("column_order", DEFAULT_COL_ORDER)
for key in col_order:
if key in data_map and cfg.get(key, True):
item = data_map[key]
if key == "show_streak_count":
icon = "🔥"
val = streak_content
tooltip = tooltip_streak
elif key == "show_non_streak":
icon = "❄️"
val = non_streak_content
tooltip = tooltip_non_streak
elif key == "show_streak_pct":
icon = "%🔥"
val = maturity_pct
tooltip = tooltip_streak_pct
else:
icon, val, tooltip = item
grid_rows += f'<div class="grid-stat-row" title="{tooltip}" style="{text_shadow_style}"><span>{icon}</span><span>{val}</span></div>'
bg_style = f'background-color:{row_bg};' if row_bg else 'background-color:var(--input-bg);'
if cover_file: bg_style = 'background-color: #000;'
border_color = ["#4da6ff", "#ff9999", "#5aff5a", "#FFD700", "#aa88ff"][depth % 5]
depth_style = f'border-left: 3px solid {border_color};' if depth > 0 else ''
html = f'''
<div class="grid-item" style="{bg_style} {depth_style}" onclick="pycmd('open:{did}')" title="{full_name_esc} - {rpg_title}">
{cover_html}
<div class="grid-header" style="{text_shadow_style}">
<div style="display:flex; align-items:center; gap:4px; overflow:hidden;">
{expander}
<span style="font-size:14px;">{rpg_icon}</span>
<span class="grid-title">{name}</span>
</div>
<div style="display:flex; align-items:center; gap:5px;">
{xp_display}
{select_checkbox}
<a href="#" onclick="pycmd('opts:{did}');event.stopPropagation();" class="grid-opt" style="{text_shadow_style}">⚙</a>
</div>
</div>
{progress_html}
<div class="grid-counts" style="{text_shadow_style}">
<span class="n{'' if new else ' z'}">{new}</span>
<span class="l{' z' if not lrn else ''}">{lrn}</span>
<span class="d{' z' if not due else ''}">{due}</span>
</div>
<div class="grid-details">
{grid_rows}
</div>
</div>
'''
if has_kids and expanded:
children = node.children
saved_order = cfg.get("child_sort_order", {}).get(str(did), [])
if saved_order:
order_map = {int(id): i for i, id in enumerate(saved_order)}
children.sort(key=lambda x: order_map.get(x.deck_id, 99999))
for c in children:
html += render_grid_node(c, depth+1, cfg, streak_thr, leech_thr)
return html
def render_pinned(deck_browser, content):
load_language()
cfg = load_config()
pinned = [d for d in cfg["pinned_ids"] if mw.col.decks.get(d)]
if len(pinned) != len(cfg["pinned_ids"]):
cfg["pinned_ids"] = pinned
save_config(cfg)
tree = mw.col.sched.deck_due_tree()
collapsed = cfg.get("is_collapsed", False)
hide_original = cfg.get("hide_original_list", False)
is_grid = cfg.get("is_grid_view", False)
streak_thr = cfg.get("streak_threshold", 20)
leech_thr = cfg.get("leech_threshold", 10)
chart_days = cfg.get("chart_days", 7)
show_charts = cfg.get("show_charts", True)
col_widths = cfg.get("col_widths", {})
import re
content.tree = re.sub(r'<a [^>]*class="(collapse|gears)"[^>]*>.*?</a>', '', content.tree, flags=re.DOTALL)
content.tree = re.sub(r'^Decks(<br>)?', '', content.tree.strip())
ghost_header = "<tr><th colspan='6' style='display:none;'></th></tr>"
table_width_val = cfg.get("table_width", 98)
if table_width_val is None: table_width_val = 98
table_width_style = f"{table_width_val}%" if table_width_val <= 100 else f"{table_width_val}px"
table_max_height = cfg.get("table_max_height", 400)
if table_max_height is None: table_max_height = 400
arrow = "▼" if not collapsed else "▶"
disp = "none" if collapsed else ("flex" if is_grid else "table")
eye_icon = "🐵" if not hide_original else "🙈"
eye_title = LANG.get("hide_default_deck_list", "Ocultar") if not hide_original else LANG.get("show_default_deck_list", "Mostrar")
grid_icon = "≡" if is_grid else "▦"
grid_title = LANG.get("toggle_list_view", "Lista") if is_grid else LANG.get("toggle_grid_view", "Grade")
study_button_html = ""
if SELECTED_FOR_STUDY:
count = len(SELECTED_FOR_STUDY)
# --- INÍCIO DA MODIFICAÇÃO ---
total_selected_cards = 0
for did in SELECTED_FOR_STUDY:
node = find_node(tree, did)
if node:
new, lrn, due = get_visual_counts(node, did)
total_selected_cards += new + lrn + due
button_text = f"{LANG.get('study_button', 'Estudar')} ({total_selected_cards})"
button_title = LANG.get('study_selected_decks', 'Estudar {count} baralhos').format(count=count) + f" ({total_selected_cards} cards)"
study_button_html = f'''
<span class="pd-btn study-btn" onclick="pycmd('study_selected')" title="{button_title}">
▶️ {button_text}
</span>
'''
# --- FIM DA MODIFICAÇÃO ---
daily = get_daily_stats()
last_review_time = get_last_review_time()
label_time = LANG.get("last_review_time_label", "Horário da última rev:")
global_streak = get_global_streak()
label_streak = LANG.get("global_streak_label", "{day} dias seguidos").format(day=global_streak)
checked_charts = "checked" if show_charts else ""
tooltip_charts = LANG.get("show_hide_charts", "Mostrar/Ocultar Gráficos")
chart_days_html = f'''
<div style="display:flex; flex-direction:column; align-items:center; margin-right:10px;">
<span style="font-size:9px; color:var(--text-muted);">{LANG.get('chart_days_label', 'Dias Gráfico')}</span>
<div style="display:flex; align-items:center; gap:2px;">
<input type="number" value="{chart_days}" onchange="pycmd('set_chart_days:'+this.value)"
style="width:35px; text-align:center; background:var(--input-bg); color:var(--text-fg); border:1px solid var(--border); border-radius:3px; font-size:10px; padding:1px;">
<input type="checkbox" {checked_charts} onchange="pycmd('toggle_charts')"
style="cursor:pointer;" title="{tooltip_charts}">
</div>
</div>
'''
daily_html = f'''
<div style="display:flex; flex-direction:column; align-items:flex-end; margin-right:15px;">
<div style="font-size:10px; color:var(--text-muted); margin-bottom:1px; font-weight:bold;">
{label_time} {last_review_time} <span style="color:#FFD700; margin-left:5px;">🔥 {label_streak}</span>
</div>
<div class="daily-stats" style="margin-right:0;" title="{LANG.get('today_answers', 'Hoje')}">
<span style="color:#ff5a5a">■ {daily[1]}</span>
<span style="color:#ff9d5a">■ {daily[2]}</span>
<span style="color:#5aff5a">■ {daily[3]}</span>
<span style="color:#5a9dff">■ {daily[4]}</span>
</div>
</div>
'''
rows = ""
sum_new, sum_lrn, sum_due = 0, 0, 0
total_seconds_global = 0
total_tomorrow, total_leeches, total_streak, total_cards_global = 0, 0, 0, 0
global_time_ms = 0
global_reviews_today = 0
global_passed_today = 0
global_goal_sum = 0
global_stars_sum = 0
total_pinned_count = len(pinned)
global_xp_sum = 0
all_pinned_and_child_dids = set()
for i, did in enumerate(pinned):
node = find_node(tree, did)
if node:
n, l, d = get_visual_counts(node, did)
sum_new += n
sum_lrn += l
sum_due += d
if cfg.get("show_time", True):
total_seconds_global += get_recursive_time_seconds(node)
deck_goal = cfg.get("deck_goals", {}).get(str(did), 100)
stats = get_deck_stats_advanced(did, streak_thr, leech_thr, deck_goal)
all_pinned_and_child_dids.update(mw.col.decks.deck_and_child_ids(did))
_, xp, _ = get_rpg_daily_stats(did)
global_xp_sum += xp
total_cards_global += stats[2]
total_tomorrow += stats[3]
total_leeches += stats[7]
total_streak += stats[8]
global_reviews_today += stats[4]
global_time_ms += stats[10]
global_stars_sum += stats[11]
global_passed_today += stats[12]
global_goal_sum += deck_goal
if is_grid:
rows += render_grid_node(node, 0, cfg, streak_thr, leech_thr)
else:
rows += render_node(node, 0, cfg, True, i, total_pinned_count, col_widths, parent_id=None)
lvl_title, lvl_color, lvl_pct, global_pct, lvl_curr, lvl_max = get_global_rpg_level(global_xp_sum)
lvl_pct_val = lvl_pct * 100
global_pct_val = global_pct * 100
if lvl_title == LANG.get("level_7_name", "LENDA"):
tooltip_level = LANG.get("max_level_reached", "Max!")
else:
tooltip_level = f"{lvl_curr}/{lvl_max} {lvl_title} ({lvl_pct_val:.1f}%)"
tooltip_global = f"{LANG.get('global_progress', 'Global')}: {global_xp_sum}/4000 ({global_pct_val:.1f}%)"
global_chart_svg_escaped = ""
if show_charts:
global_daily_data = get_global_daily_summary(chart_days, dids=list(all_pinned_and_child_dids))
if global_daily_data:
today_date_str = global_daily_data[-1][0]
global_daily_data[-1] = (today_date_str, global_reviews_today, global_xp_sum)
processed_data_for_chart = []
for date, cards, daily_xp in global_daily_data:
level_title, _, _, _, _, _ = get_global_rpg_level(daily_xp)
processed_data_for_chart.append((date, cards, daily_xp, level_title))
global_chart_svg = generate_global_stats_svg(processed_data_for_chart)
global_chart_svg_escaped = html_lib.escape(global_chart_svg)
global_level_html = f'''
<div id="global-level-container"
style="flex-grow:1; margin:0 15px; display:flex; flex-direction:column; justify-content:center;"
onmouseover="showFixedChart(this, event)" onmouseout="hideChart()"
data-chart="{global_chart_svg_escaped}">
<div style="display:flex; justify-content:space-between; font-size:10px; color:var(--text-muted); margin-bottom:2px;">
<span>{LANG.get("level", "Nvl")} {lvl_title}</span>
<span>{global_xp_sum} XP</span>
</div>
<div title="{tooltip_level}" style="width:100%; height:4px; background:rgba(127,127,127,0.3); border-radius:2px; margin-bottom:2px; overflow:hidden; cursor:help;">
<div style="width:{lvl_pct_val}%; height:100%; background:{lvl_color}; transition: width 0.5s;"></div>
</div>
<div title="{tooltip_global}" style="width:100%; height:4px; background:rgba(127,127,127,0.3); border-radius:2px; overflow:hidden; cursor:help;">
<div style="width:{global_pct_val}%; height:100%; background:linear-gradient(90deg, #4da6ff, #aa88ff); transition: width 0.5s;"></div>
</div>
</div>
'''
current_sort = cfg.get("last_sort_col", "")
is_desc = cfg.get("last_sort_desc", True)
def get_sort_indicator(col_name):
if current_sort == col_name:
return " ▼" if is_desc else " ▲"
return ""
def add_col(key, title, tooltip, footer_val=""):
if cfg.get(key, True):
w = col_widths.get(key, 0)
w_style = f"width:{w}px;" if w > 0 else ""
move_controls = f'''
<div class="col-move">
<span onclick="pycmd('move_col:{key},left');event.stopPropagation();" title="{LANG.get('move_left', '<')}">&lt;</span>
<span onclick="pycmd('move_col:{key},right');event.stopPropagation();" title="{LANG.get('move_right', '>')}">&gt;</span>
</div>
'''
sort_arrow = get_sort_indicator(key)
cursor_style = "cursor:pointer;"
header_html = f'''
<td class="col-header" title="{tooltip} ({LANG.get('click_to_sort', 'Ordenar')})"
onclick="pycmd('sort:{key}')"
style="font-size:9px; text-align:center; color:var(--text-muted); vertical-align:bottom; position:relative; {w_style} {cursor_style}"
data-col="{key}">
{move_controls}
{title}{sort_arrow}
<div class="resizer" onmousedown="rsStart(event, '{key}')"></div>
</td>'''
footer_html = f'<td style="text-align:center; color:var(--text-fg); font-size:11px;">{footer_val}</td>'
return header_html, footer_html
return "", ""
avg_global_str = "-"
if global_reviews_today > 0:
avg_global = (global_time_ms / 1000) / global_reviews_today
avg_global_str = f"{avg_global:.1f}s"
global_speed_str = "-"
if global_time_ms > 0:
global_time_min = global_time_ms / 60000
if global_time_min > 0:
global_cpm = global_reviews_today / global_time_min
global_speed_str = f"{global_cpm:.1f}"
global_retention_str = "-"
if global_reviews_today > 0:
global_retention_str = f"{(global_passed_today / global_reviews_today) * 100:.0f}%"
global_ease_str = "-"
if pinned:
all_pinned_ids_str = ",".join(str(d) for d in pinned)
global_avg_ease = mw.col.db.scalar(f"SELECT avg(factor) FROM cards WHERE did IN ({all_pinned_ids_str}) AND queue != 0")
if global_avg_ease:
global_ease_str = f"{global_avg_ease/10:.0f}%"
streak_footer_count = total_streak
streak_footer_pct = "0%"
if total_cards_global > 0:
pct = (total_streak / total_cards_global) * 100
streak_footer_pct = f"{pct:.0f}%"
stars_footer = ""
if global_stars_sum > 0:
stars_footer = f'<span style="color:#FFD700; font-weight:bold; margin-left:2px;">⭐{global_stars_sum}</span>'
total_time_footer = format_time_str(total_seconds_global)
total_non_streak = total_cards_global - total_streak
col_defs = {
"show_time": ("⏱️", LANG.get("estimated_time_tooltip", "Tempo"), total_time_footer),
"show_avg_time": ("s/card", LANG.get("avg_seconds_per_card", "Média"), avg_global_str),
"show_speed": ("🚀", LANG.get("speed_tooltip", "Velocidade"), global_speed_str),
"show_goal": ("🎯", LANG.get("daily_goal_reviews_tooltip", "Meta"), f"{global_goal_sum}{stars_footer}"),
"show_retention": ("% Hj", LANG.get("retention_rate_today", "Retenção"), global_retention_str),
"show_ease": ("⚖️", LANG.get("avg_ease_tooltip", "Ease"), global_ease_str),
"show_leeches": (f'''🩸<br><input type="number" value="{leech_thr}" onclick="event.stopPropagation()" onchange="pycmd('set_leech:'+this.value)" style="width:35px; text-align:center; background:var(--input-bg); color:var(--text-fg); border:1px solid var(--border); border-radius:3px; font-size:10px; padding:1px;">''',
LANG.get("leeches_tooltip_long", "Sanguessugas"),
f"{total_leeches if total_leeches > 0 else ''}"),
"show_tomorrow": ("🔮", LANG.get("cards_scheduled_for_tomorrow", "Amanhã"), total_tomorrow),
"show_total": (LANG.get("total_tooltip", "Total"), LANG.get("total_cards_in_deck", "Total"), total_cards_global),
"show_streak_count": (f'''Streak<br><input type="number" value="{streak_thr}" onclick="event.stopPropagation()" onchange="pycmd('set_streak:'+this.value)" style="width:35px; text-align:center; background:var(--input-bg); color:var(--text-fg); border:1px solid var(--border); border-radius:3px; font-size:10px; padding:1px;">''',
LANG.get("mature_cards_count", "Streak"),
streak_footer_count),
"show_non_streak": (LANG.get("non_streak", "Não Streak"), LANG.get("non_streak", "Não Streak"), total_non_streak),
"show_streak_pct": ("Streak %", LANG.get("mature_cards_pct", "Streak %"), streak_footer_pct)
}
header_cols = ""
footer_cols = ""
col_order = cfg.get("column_order", DEFAULT_COL_ORDER)
for key in col_order:
if key in col_defs:
title, tooltip, foot_val = col_defs[key]
h, f = add_col(key, title, tooltip, foot_val)
header_cols += h
footer_cols += f
total_cols_count = 5 + sum(1 for k in col_defs.keys() if cfg.get(k, True))
w_nm = col_widths.get("col_name", 300)
style_nm = f"width:{w_nm}px;"
w_st = col_widths.get("col_counts", 160)
style_st = f"width:{w_st}px;"
w_op = col_widths.get("col_opts", 50)
style_op = f"width:{w_op}px;"
w_ord = col_widths.get("col_ord", 40)
style_ord = f"width:{w_ord}px;"
w_sel = col_widths.get("col_select", 30)
style_sel = f"width:{w_sel}px;"
if not is_grid:
if rows:
arrow_nm = get_sort_indicator("col_name")
arrow_st = get_sort_indicator("col_counts")
header_html = f'''
<tr style="font-size:10px; color:var(--text-muted); line-height:1;">
<td class="col-header" style="position:relative; {style_ord}" data-col="col_ord">
<div class="resizer-left" onmousedown="rsStartContainer(event, 'left')"></div>
<div class="resizer" onmousedown="rsStart(event, 'col_ord')"></div>
</td>
<td class="col-header" style="position:relative; {style_sel}; text-align:center;" data-col="col_select">
<div class="resizer" onmousedown="rsStart(event, 'col_select')"></div>
</td>
<td class="col-header" onclick="pycmd('sort:col_name')" title="{LANG.get('sort_by_name', 'Nome')}"
style="position:relative; cursor:pointer; {style_nm}" data-col="col_name">
{arrow_nm}
<div class="resizer" onmousedown="rsStart(event, 'col_name')"></div>
</td>
<td class="st col-header" onclick="pycmd('sort:col_counts')" title="{LANG.get('sort_by_count', 'Contagem')}"
style="padding-bottom:2px; vertical-align:bottom; position:relative; cursor:pointer; {style_st}" data-col="col_counts">
<span class="n" style="color:#7cf; font-weight:bold;">{LANG.get("new", "Novos")}</span>
<span class="l" style="color:#f99; font-weight:bold;">{LANG.get("learn", "Apr.")}</span>
<span class="d" style="color:#4CAF50; font-weight:bold;">{LANG.get("review", "Rev.")}</span>{arrow_st}
<div class="resizer" onmousedown="rsStart(event, 'col_counts')"></div>
</td>
{header_cols}
<td class="col-header" style="position:relative; {style_op}" data-col="col_opts">
<div class="resizer" onmousedown="rsStart(event, 'col_opts')"></div>
<div class="resize-handle-right" onmousedown="rsStartContainer(event, 'right')"></div>
</td>
</tr>
<tr style="background:rgba(127,127,127,0.1); border-bottom:1px solid var(--border); font-weight:bold;">
<td></td>
<td></td>
<td class="nm" style="text-align:right; padding-right:10px; color:var(--text-fg); font-style:italic;">{LANG.get("totals", "Totais")}</td>
<td class="st">
<span class="n">{sum_new}</span>
<span class="l">{sum_lrn}</span>
<span class="d">{sum_due}</span>
</td>
{footer_cols}
<td></td>
</tr>
'''
rows = header_html + rows
else:
rows = f'<tr><td colspan="{total_cols_count}" style="text-align:center;padding:20px;color:var(--text-muted)">{LANG.get("no_pinned_decks", "Vazio")}</td></tr>'
else:
if not rows:
rows = f'<div style="text-align:center;padding:20px;color:var(--text-muted);width:100%;">{LANG.get("no_pinned_decks", "Vazio")}</div>'
flag_br_b64 = image_to_base64("_user_files/br.jpg")
flag_us_b64 = image_to_base64("_user_files/us.jpg")
current_lang = cfg.get("language", "pt")
active_class_pt = 'class="active"' if current_lang == 'pt' else ''
active_class_en = 'class="active"' if current_lang == 'en' else ''
lang_selector_html = f'''
<div class="lang-selector">
<a href="#" onclick="pycmd('set_lang:pt')"><img src="{flag_br_b64}" {active_class_pt} title="{LANG.get('lang_pt', 'PT')}"></a>
<a href="#" onclick="pycmd('set_lang:en')"><img src="{flag_us_b64}" {active_class_en} title="{LANG.get('lang_en', 'EN')}"></a>
</div>
'''
extra = f"""
<style>
.pdb-outer {{ display: flex; justify-content: center; width: 100%; }}
.pdb {{
--bg: #ffffff; --text-fg: #000000; --text-muted: #888888; --border: #cccccc;
--header-bg: #e0e0e0; --input-bg: #ffffff; --progress-bg: #dddddd;
}}
.night .pdb {{
--bg: #333333; --text-fg: #ffffff; --text-muted: #aaaaaa; --border: #555555;
--header-bg: #444444; --input-bg: #222222; --progress-bg: #444444;
}}
.night .n:not(.z), .night .l:not(.z), .night .d:not(.z) {{
color: white !important; border-radius: 4px; padding: 1px 0; font-weight: bold; background-color: #555;
}}
.night .n:not(.z) {{ background-color: #0277BD; }}
.night .l:not(.z) {{ background-color: #D32F2F; }}
.night .d:not(.z) {{ background-color: #388E3C; }}
.pdb {{
background: var(--bg); color: var(--text-fg); border: 1px solid var(--border);
border-radius: 8px; margin-bottom: 16px; overflow: hidden; box-sizing: border-box;
overflow-x: auto; overflow-y: auto; position: relative;
width: {table_width_style}; max-width: 100%; max-height: {table_max_height}px;
flex: 0 0 auto;
}}
.pdh {{
background: var(--header-bg); color: var(--text-fg); padding: 8px 14px; font-weight: bold;
cursor: pointer; display: flex; justify-content: space-between; align-items: center;
user-select: none; border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100;
}}
.pdb a {{ color: var(--text-fg) !important; text-decoration: none; }}
.pdb a:hover {{ text-decoration: underline; opacity: 0.8; }}
.pd-controls {{display:flex; gap:15px; align-items:center;}}
.pd-btn {{cursor:pointer; font-size:18px; opacity:0.7; transition:opacity 0.2s;}}
.pd-btn:hover {{opacity:1; transform:scale(1.1);}}
.daily-stats {{ font-size: 12px; font-weight: normal; background:rgba(127,127,127,0.2); padding:2px 8px; border-radius:4px; }}
.daily-stats span {{ margin-left: 6px; }}
.daily-stats span:first-child {{ margin-left: 0; }}
.lang-selector {{ display: flex; align-items: center; gap: 8px; margin-right: 15px; }}
.lang-selector img {{ width: 24px; height: 16px; border-radius: 2px; border: 1px solid var(--border); cursor: pointer; opacity: 0.7; box-sizing: border-box; }}
.lang-selector img:hover {{ opacity: 1; transform: scale(1.1); }}
.lang-selector img.active {{
border: 2px solid #4da6ff;
opacity: 1;
}}
.pdb table {{ width: 100%; border-collapse: collapse; table-layout: fixed; }}
.grid-container {{ display: flex; flex-wrap: wrap; gap: 10px; padding: 10px; justify-content: flex-start; }}
.grid-item {{ background: var(--input-bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px; width: 160px; cursor: pointer; display: flex; flex-direction: column; gap: 5px; transition: transform 0.1s, box-shadow 0.1s; position: relative; overflow: hidden; }}
.grid-item:hover {{ transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.2); }}
.grid-header {{ display: flex; justify-content: space-between; align-items: center; font-weight: bold; font-size: 12px; border-bottom: 1px solid var(--border); padding-bottom: 4px; margin-bottom: 2px; }}
.grid-title {{ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-grow: 1; margin: 0 4px; }}
.grid-opt {{ font-size: 12px; opacity: 0.5; }}
.grid-opt:hover {{ opacity: 1; }}
.grid-counts {{ display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 4px; }}
.grid-details {{ display: flex; flex-direction: column; gap: 2px; }}
.grid-stat-row {{ display: flex; justify-content: space-between; font-size: 10px; color: var(--text-muted); border-bottom: 1px solid rgba(127,127,127,0.1); padding: 1px 0; }}
.grid-exp {{ cursor: pointer; color: #4da6ff; font-weight: bold; margin-right: 2px; }}
tr.pr {{ transition: filter 0.1s; }}
tr.pr:hover {{ filter: brightness(0.92); }}
.night tr.pr:hover {{ filter: brightness(1.15); }}
tr.pr:hover > td {{ background-color: transparent !important; }}
tr.pr[data-did]{{cursor:grab}}
tr.pr.drag{{opacity:0.4; background:var(--header-bg) !important;}}
tr.pr.over{{ border-top: 3px solid #ff6f00 !important; background: rgba(255, 111, 0, 0.2) !important; }}
.exp{{cursor:pointer; color:#4da6ff; margin-right:6px; font-weight:bold}}
.expph{{display:inline-block; width:16px}}
td.st {{ white-space: nowrap; text-align: right; padding-right: 10px; }}
.n, .l, .d, .z {{ display: inline-block; width: 45px; text-align: right; margin-left: 5px; }}
.n{{color:#7cf}} .l{{color:#f99}} .d{{color:#4CAF50}} .z{{color:var(--text-muted)}}
td.mat {{ width: 75px; text-align: center; color: var(--text-muted); font-size: 11px; white-space: nowrap; }}
td.inf {{ width: 35px; text-align: center; color: var(--text-muted); font-size: 11px; }}
input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button {{ -webkit-appearance: none; margin: 0; }}
.ord-col {{ width: 60px; text-align: center; vertical-align: middle; }}
.ord-wrapper {{ display: flex; align-items: center; justify-content: center; gap: 3px; }}
.arr-btn {{ cursor: pointer; color: var(--text-muted); font-size: 12px; display: inline-block; padding: 0 4px; }}
.arr-btn:hover {{ color: var(--text-fg); background: rgba(127,127,127,0.2); border-radius: 2px; }}
.arr-ph {{ display: inline-block; width: 12px; }}
.oi {{ width: 24px; text-align: center; border: 1px solid var(--border); background: var(--input-bg); color: var(--text-fg); border-radius: 3px; font-size: 10px; margin: 0; padding: 1px 0; }}
.goal-input {{ width: 30px; text-align: center; border: 1px solid var(--border); background: var(--input-bg); color: var(--text-fg); border-radius: 3px; font-size: 10px; margin: 0; padding: 1px 0; }}
.col-move {{ display: none; position: absolute; top: 0; left: 0; width: 100%; text-align: center; font-size: 9px; background: rgba(0,0,0,0.5); color: white; z-index: 25; }}
.col-header:hover .col-move {{ display: block; }}
.col-move span {{ cursor: pointer; padding: 0 4px; font-weight: bold; }}
.col-move span:hover {{ color: #4da6ff; }}
.resizer {{ position: absolute; top: 0; right: 0; width: 6px; cursor: col-resize; user-select: none; height: 100%; z-index: 20; border-right: 1px solid var(--border); }}
.resizer:hover {{ background: rgba(127, 127, 127, 0.3); border-right: 2px solid #4da6ff; }}
.resize-handle-left, .resize-handle-right {{ position: absolute; top: 0; bottom: 0; width: 12px; cursor: ew-resize; z-index: 50; }}
.resize-handle-left {{ left: 0; }}
.resize-handle-right {{ right: 0; }}
.resize-handle-left:hover, .resize-handle-right:hover {{ background: rgba(77, 166, 255, 0.3); }}
.resize-handle-bottom {{ position: absolute; left: 0; right: 0; bottom: 0; height: 12px; cursor: ns-resize; z-index: 60; }}
.resize-handle-bottom:hover {{ background: rgba(77, 166, 255, 0.3); }}
.resize-handle-top {{ position: absolute; left: 0; right: 0; top: 0; height: 10px; cursor: ns-resize; z-index: 110; }}
.resize-handle-top:hover {{ background: rgba(77, 166, 255, 0.3); }}
.col-header {{ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.pdb td {{ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.pd-btn.study-btn {{
font-size: 14px;
font-weight: bold;
color: #5aff5a;
background: rgba(90, 255, 90, 0.15);
padding: 3px 10px;
border-radius: 5px;
border: 1px solid rgba(90, 255, 90, 0.3);
}}
.pd-btn.study-btn:hover {{
color: #fff;
background: #5aff5a;
transform: scale(1.05);
}}
.sel-col {{
text-align: center;
vertical-align: middle;
}}
.study-cb {{
cursor: pointer;
width: 16px;
height: 16px;
vertical-align: middle;
}}
#chart-tooltip {{
position: fixed;
display: none;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 99999;
padding: 5px;
max-width: 620px;
}}
</style>
<div id="chart-tooltip"></div>
<script>
var srcDid = null;
window.pdStart = function(e){{ if(e.target.tagName === "INPUT") return; var tr = e.target.closest("tr[data-did]"); if(!tr) return; srcDid = tr.dataset.did; tr.classList.add("drag"); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", srcDid); }};
window.pdOver = function(e){{ e.preventDefault(); var tr = e.target.closest("tr[data-did]"); if(tr && tr.dataset.did !== srcDid) tr.classList.add("over"); }};
window.pdLeave = function(e){{ var tr = e.target.closest("tr[data-did]"); if(tr) tr.classList.remove("over"); }};
window.pdDrop = function(e){{ e.preventDefault(); e.stopPropagation(); document.querySelectorAll("tr.pr").forEach(r=>r.classList.remove("over", "drag")); var targetTr = e.target.closest("tr[data-did]"); var targetDid = targetTr ? targetTr.dataset.did : null; var droppedDid = e.dataTransfer.getData("text/plain") || e.dataTransfer.getData("anki-did") || srcDid; if(!droppedDid) return; if(targetDid && droppedDid !== targetDid) pycmd("insert_at:" + droppedDid + "," + targetDid); else if (!targetDid) pycmd("pin_end:" + droppedDid); srcDid = null; }};
window.pdBoxDrop = function(e){{ e.preventDefault(); var droppedDid = e.dataTransfer.getData("anki-did") || srcDid; if(droppedDid) pycmd("pin_end:" + droppedDid); }};
new MutationObserver(()=>{{ document.querySelectorAll('tr[id^="did"]:not([data-pd])').forEach(r=>{{ r.dataset.pd = "1"; r.draggable = true; r.ondragstart = e => {{ e.dataTransfer.setData("anki-did", r.id.slice(3)); }}; }}); }}).observe(document.body, {{childList:true, subtree:true}});
var rsCol = null, rsStartX = 0, rsStartW = 0;
window.rsStart = function(e, colName) {{ e.preventDefault(); e.stopPropagation(); rsCol = colName; rsStartX = e.pageX; var td = e.target.closest("td"); rsStartW = td.offsetWidth; document.addEventListener("mousemove", rsMove); document.addEventListener("mouseup", rsUp); document.body.style.cursor = "col-resize"; }};
function rsMove(e) {{ if(!rsCol) return; var diff = e.pageX - rsStartX; var newW = rsStartW + diff; if(newW < 20) newW = 20; var tds = document.querySelectorAll('td[data-col="'+rsCol+'"]'); tds.forEach(function(td){{ td.style.width = newW + "px"; }}); }}
function rsUp(e) {{ document.removeEventListener("mousemove", rsMove); document.removeEventListener("mouseup", rsUp); document.body.style.cursor = "default"; if(rsCol) {{ var diff = e.pageX - rsStartX; var newW = rsStartW + diff; if(newW < 20) newW = 20; pycmd("resize:" + rsCol + "," + newW); }} rsCol = null; }}
var rsContStartVal = 0, rsContStartX = 0, rsContStartY = 0, rsContSide = null;
window.rsStartContainer = function(e, side) {{ e.preventDefault(); e.stopPropagation(); rsContSide = side; rsContStartX = e.pageX; rsContStartY = e.pageY; var pdb = document.querySelector('.pdb'); if (side === 'left' || side === 'right') {{ rsContStartVal = pdb.offsetWidth; document.body.style.cursor = "ew-resize"; }} else if (side === 'bottom' || side === 'top') {{ rsContStartVal = pdb.offsetHeight; document.body.style.cursor = "ns-resize"; }} document.addEventListener("mousemove", rsMoveContainer); document.addEventListener("mouseup", rsUpContainer); }};
function rsMoveContainer(e) {{ if(!rsContSide) return; if (rsContSide === 'right') {{ var diff = e.pageX - rsContStartX; var newW = rsContStartVal + diff; if(newW < 400) newW = 400; document.querySelector('.pdb').style.width = newW + "px"; }} else if (rsContSide === 'left') {{ var diff = e.pageX - rsContStartX; var newW = rsContStartVal - diff; if(newW < 400) newW = 400; document.querySelector('.pdb').style.width = newW + "px"; }} else if (rsContSide === 'bottom') {{ var diff = e.pageY - rsContStartY; var newH = rsContStartVal + diff; if(newH < 100) newH = 100; document.querySelector('.pdb').style.maxHeight = newH + "px"; }} else if (rsContSide === 'top') {{ var diff = e.pageY - rsContStartY; var newH = rsContStartVal - diff; if(newH < 100) newH = 100; document.querySelector('.pdb').style.maxHeight = newH + "px"; }} }}
function rsUpContainer(e) {{ document.removeEventListener("mousemove", rsMoveContainer); document.removeEventListener("mouseup", rsUpContainer); document.body.style.cursor = "default"; if(rsContSide) {{ var pdb = document.querySelector('.pdb'); if (rsContSide === 'left' || rsContSide === 'right') {{ pycmd("resize_container:" + pdb.offsetWidth); }} else if (rsContSide === 'bottom' || rsContSide === 'top') {{ pycmd("resize_height:" + pdb.offsetHeight); }} }} rsContSide = null; }}
var chartTooltip = document.getElementById('chart-tooltip');
var hideChartTimer;
window.showFixedChart = function(el, event) {{
clearTimeout(hideChartTimer);
var svgContent = el.dataset.chart;
if (!svgContent) return;
chartTooltip.innerHTML = svgContent;
chartTooltip.style.display = 'block';
chartTooltip.style.pointerEvents = 'auto';
moveChart(event);
}};
window.showMovingChart = function(el, event) {{
clearTimeout(hideChartTimer);
var svgContent = el.dataset.chart;
if (!svgContent) return;
chartTooltip.innerHTML = svgContent;
chartTooltip.style.display = 'block';
chartTooltip.style.pointerEvents = 'none';
moveChart(event);
}};
window.moveChart = function(e) {{
if (!e || chartTooltip.style.display !== 'block') return;
var tooltipWidth = chartTooltip.offsetWidth;
var tooltipHeight = chartTooltip.offsetHeight;
var x = e.clientX + 15;
var y = e.clientY + 15;
if (x + tooltipWidth > window.innerWidth) {{
x = e.clientX - tooltipWidth - 15;
}}
if (y + tooltipHeight > window.innerHeight) {{
y = e.clientY - tooltipHeight - 15;
}}
chartTooltip.style.left = x + 'px';
chartTooltip.style.top = y + 'px';
}};
window.hideChart = function() {{
hideChartTimer = setTimeout(function() {{
chartTooltip.style.display = 'none';
}}, 300);
}};
chartTooltip.addEventListener('mouseover', function() {{ clearTimeout(hideChartTimer); }});
chartTooltip.addEventListener('mouseout', function() {{ hideChart(); }});
</script>
"""
theme_class = "night" if theme_manager.night_mode else ""
content_html = ""
if is_grid:
content_html = f'<div class="grid-container">{rows}</div>'
else:
content_html = f'<table style="display:{disp};">{rows}</table>'
pinned_html = f'''
{extra}
<tr class="pd-wrapper-row">
<td colspan="20" style="padding:0; border:none;">
<div class="{theme_class}">
<div class="pdb-outer">
<div class="pdb" ondragover="event.preventDefault()" ondrop="pdBoxDrop(event)">
<div class="resize-handle-top" onmousedown="rsStartContainer(event, 'top')"></div>
<div class="resize-handle-left" onmousedown="rsStartContainer(event, 'left')"></div>
<div class="pdh">
<div onclick="pycmd('colap')" style="flex-grow:0; white-space:nowrap; margin-right:10px;">{LANG.get("pinned_decks_title", "Decks Fixados")} ({len(pinned)})</div>
{global_level_html}
<div class="pd-controls">
{lang_selector_html}
{chart_days_html}
{daily_html}
{study_button_html}
<span class="pd-btn" onclick="pycmd('toggle_grid')" title="{grid_title}">{grid_icon}</span>
<span class="pd-btn" onclick="pycmd('export_html')" title="{LANG.get('generate_html_report', 'Relatório')}">📄</span>
<span class="pd-btn" onclick="pycmd('toggle_original')" title="{eye_title}">{eye_icon}</span>
<span class="pd-btn" onclick="pycmd('colap')">{arrow}</span>
</div>
</div>
{content_html}
<div class="resize-handle-right" onmousedown="rsStartContainer(event, 'right')"></div>
<div class="resize-handle-bottom" onmousedown="rsStartContainer(event, 'bottom')"></div>
</div>
</div>
</div>
</td>
</tr>
'''
if hide_original:
content.tree = ghost_header + pinned_html
else:
content.tree = ghost_header + pinned_html + content.tree
# ==================== COMANDOS ====================
def update_child_order(c, parent_id, child_list):
if "child_sort_order" not in c:
c["child_sort_order"] = {}
c["child_sort_order"][str(parent_id)] = child_list
save_config(c)
def get_children_order(c, parent_id):
saved = c.get("child_sort_order", {}).get(str(parent_id), [])
if saved: return saved
tree = mw.col.sched.deck_due_tree()
node = find_node(tree, parent_id)
if node:
return [child.deck_id for child in node.children]
return []
def export_html_report():
cfg = load_config()
pinned = [d for d in cfg["pinned_ids"] if mw.col.decks.get(d)]
tree = mw.col.sched.deck_due_tree()
streak_thr = cfg.get("streak_threshold", 20)
leech_thr = cfg.get("leech_threshold", 10)
rows_data = []
totals = {
"new": 0, "lrn": 0, "due": 0,
"time_ms": 0, "reviews": 0, "passed": 0,
"streak": 0, "cards": 0, "stars": 0,
"goal": 0, "leeches": 0, "tomorrow": 0,
"xp": 0, "time_seconds": 0
}
def process_node(node, depth):
did = node.deck_id
new, lrn, due = get_visual_counts(node, did)
deck_goal = cfg.get("deck_goals", {}).get(str(did), 100)
stats = get_deck_stats_advanced(did, streak_thr, leech_thr, deck_goal)
rpg = get_rpg_daily_stats(did)
rec_seconds = get_recursive_time_seconds(node)
if depth == 0:
totals["new"] += new
totals["lrn"] += lrn
totals["due"] += due
totals["cards"] += stats[2]
totals["tomorrow"] += stats[3]
totals["reviews"] += stats[4]
totals["leeches"] += stats[7]
totals["streak"] += stats[8]
totals["time_ms"] += stats[10]
totals["stars"] += stats[11]
totals["passed"] += stats[12]
totals["goal"] += deck_goal
totals["xp"] += rpg[1]
totals["time_seconds"] += rec_seconds
row = {
"name": node.name.split("::")[-1],
"full_name": node.name,
"did": did,
"depth": depth,
"counts": (new, lrn, due),
"stats": stats[:15],
"rpg": rpg,
"goal": deck_goal,
"bg_color": cfg.get("deck_colors", {}).get(str(did), ""),
"has_children": len(node.children) > 0,
"expanded": did in cfg.get("expanded_ids", []),
"recursive_seconds": rec_seconds,
"ease_counts": stats[13]
}
rows_data.append(row)
if len(node.children) > 0 and did in cfg.get("expanded_ids", []):
children = node.children
saved_order = cfg.get("child_sort_order", {}).get(str(did), [])
if saved_order:
order_map = {int(id): i for i, id in enumerate(saved_order)}
children.sort(key=lambda x: order_map.get(x.deck_id, 99999))
for child in children:
process_node(child, depth + 1)
for did in pinned:
node = find_node(tree, did)
if node:
process_node(node, 0)
daily_stats = get_daily_stats()
last_rev = get_last_review_time()
glob_streak = get_global_streak()
html_content = report_html.generate_report(
rows_data, totals, daily_stats, cfg,
theme_manager.night_mode, mw.col.media.dir(),
LANG, last_rev, glob_streak
)
try:
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as tf:
tf.write(html_content)
tf_path = tf.name
webbrowser.open(f"file:///{tf_path}")
tooltip(LANG.get("html_report_generated", "Relatório gerado!"))
except Exception as e:
tooltip(f"Erro ao gerar HTML: {e}")
def handler(cmd):
if cmd == "colap":
c = load_config()
c["is_collapsed"] = not c.get("is_collapsed", False)
save_config(c)
mw.deckBrowser.refresh()
elif cmd == "toggle_original":
c = load_config()
c["hide_original_list"] = not c.get("hide_original_list", False)
save_config(c)
mw.deckBrowser.refresh()
elif cmd.startswith("select_deck:"):
try:
did = int(cmd.split(":")[1])
if did in SELECTED_FOR_STUDY:
SELECTED_FOR_STUDY.remove(did)
else:
SELECTED_FOR_STUDY.add(did)
mw.deckBrowser.refresh()
except:
pass
elif cmd == "study_selected":
start_custom_study_session()
elif cmd == "toggle_grid":
c = load_config()
c["is_grid_view"] = not c.get("is_grid_view", False)
save_config(c)
mw.deckBrowser.refresh()
elif cmd == "export_html":
export_html_report()
elif cmd.startswith("sort:"):
col = cmd.split(":")[1]
sort_pinned_decks(col)
elif cmd.startswith("browser:"):
query = cmd[8:]
browser = dialogs.open("Browser", mw)
browser.setFilter(query)
elif cmd.startswith("set_lang:"):
lang_code = cmd.split(":")[1]
if lang_code in ["pt", "en"]:
c = load_config()
c["language"] = lang_code
save_config(c)
clear_stats_cache()
mw.deckBrowser.refresh()
elif cmd.startswith("resize_container:"):
try:
width = int(float(cmd.split(":")[1]))
c = load_config()
c["table_width"] = width
save_config(c)
except: pass
elif cmd.startswith("resize_height:"):
try:
height = int(float(cmd.split(":")[1]))
c = load_config()
c["table_max_height"] = height
save_config(c)
except: pass
elif cmd.startswith("resize:"):
try:
parts = cmd[7:].split(",")
col_name = parts[0]
width = int(float(parts[1]))
c = load_config()
if "col_widths" not in c: c["col_widths"] = {}
c["col_widths"][col_name] = width
save_config(c)
except Exception as e:
print("Erro resize:", e)
elif cmd.startswith("move_col:"):
try:
parts = cmd[9:].split(",")
key = parts[0]
direction = parts[1]
c = load_config()
order = c.get("column_order", DEFAULT_COL_ORDER)
if key in order:
idx = order.index(key)
if direction == "left" and idx > 0:
order[idx], order[idx-1] = order[idx-1], order[idx]
elif direction == "right" and idx < len(order) - 1:
order[idx], order[idx+1] = order[idx+1], order[idx]
c["column_order"] = order
save_config(c)
mw.deckBrowser.refresh()
except Exception as e:
print("Erro move_col:", e)
elif cmd.startswith("exp:"):
did = int(cmd[4:])
c = load_config()
ex = set(c.get("expanded_ids", []))
ex.symmetric_difference_update([did])
c["expanded_ids"] = list(ex)
save_config(c)
mw.deckBrowser.refresh()
elif cmd.startswith("pin_end:"):
did = int(cmd[8:])
c = load_config()
ids = c["pinned_ids"]
if did not in ids:
ids.append(did)
c["pinned_ids"] = ids
save_config(c)
mw.deckBrowser.refresh()
elif cmd.startswith("insert_at:"):
try:
src, tgt = map(int, cmd[10:].split(","))
c = load_config()
ids = c["pinned_ids"]
if src in ids: ids.remove(src)
if tgt in ids:
idx = ids.index(tgt)
ids.insert(idx, src)
else:
ids.append(src)
c["pinned_ids"] = ids
save_config(c)
mw.deckBrowser.refresh()
except: pass
elif cmd.startswith("move_up:"):
try:
parts = cmd.split(":")[1].split(",")
did = int(parts[0])
parent_id = int(parts[1]) if len(parts) > 1 and parts[1] else None
c = load_config()
if parent_id:
ids = get_children_order(c, parent_id)
if did in ids:
idx = ids.index(did)
if idx > 0:
ids[idx], ids[idx-1] = ids[idx-1], ids[idx]
update_child_order(c, parent_id, ids)
mw.deckBrowser.refresh()
else:
ids = c["pinned_ids"]
if did in ids:
idx = ids.index(did)
if idx > 0:
ids[idx], ids[idx-1] = ids[idx-1], ids[idx]
save_config(c)
mw.deckBrowser.refresh()
except Exception as e: print(e)
elif cmd.startswith("move_down:"):
try:
parts = cmd.split(":")[1].split(",")
did = int(parts[0])
parent_id = int(parts[1]) if len(parts) > 1 and parts[1] else None
c = load_config()
if parent_id:
ids = get_children_order(c, parent_id)
if did in ids:
idx = ids.index(did)
if idx < len(ids) - 1:
ids[idx], ids[idx+1] = ids[idx+1], ids[idx]
update_child_order(c, parent_id, ids)
mw.deckBrowser.refresh()
else:
ids = c["pinned_ids"]
if did in ids:
idx = ids.index(did)
if idx < len(ids) - 1:
ids[idx], ids[idx+1] = ids[idx+1], ids[idx]
save_config(c)
mw.deckBrowser.refresh()
except Exception as e: print(e)
elif cmd.startswith("ord:"):
try:
parts = cmd[4:].split(",")
did_movido = int(parts[0])
parent_str = parts[1]
parent_id = int(parent_str) if parent_str else None
nova_posicao = int(parts[2])
c = load_config()
if parent_id:
ids = get_children_order(c, parent_id)
if did_movido in ids:
indice_atual = ids.index(did_movido)
indice_destino = max(0, min(nova_posicao - 1, len(ids) - 1))
ids[indice_atual], ids[indice_destino] = ids[indice_destino], ids[indice_atual]
update_child_order(c, parent_id, ids)
mw.deckBrowser.refresh()
else:
ids = c["pinned_ids"]
if did_movido in ids:
indice_atual = ids.index(did_movido)
indice_destino = max(0, min(nova_posicao - 1, len(ids) - 1))
ids[indice_atual], ids[indice_destino] = ids[indice_destino], ids[indice_atual]
c["pinned_ids"] = ids
save_config(c)
mw.deckBrowser.refresh()
except Exception as e:
print(e)
elif cmd.startswith("set_streak:"):
try:
val = int(cmd.split(":")[1])
if val < 1: val = 1
c = load_config()
c["streak_threshold"] = val
save_config(c)
clear_stats_cache()
mw.deckBrowser.refresh()
except: pass
elif cmd.startswith("set_leech:"):
try:
val = int(cmd.split(":")[1])
if val < 1: val = 1
c = load_config()
c["leech_threshold"] = val
save_config(c)
clear_stats_cache()
mw.deckBrowser.refresh()
except: pass
elif cmd.startswith("set_chart_days:"):
try:
val = int(cmd.split(":")[1])
if val < 3: val = 3
c = load_config()
c["chart_days"] = val
save_config(c)
clear_stats_cache()
mw.deckBrowser.refresh()
except: pass
elif cmd.startswith("toggle_charts"):
c = load_config()
c["show_charts"] = not c.get("show_charts", True)
save_config(c)
clear_stats_cache()
mw.deckBrowser.refresh()
elif cmd.startswith("set_goal:"):
try:
parts = cmd[9:].split(",")
did = parts[0]
val = int(parts[1])
if val < 1: val = 1
c = load_config()
if "deck_goals" not in c: c["deck_goals"] = {}
c["deck_goals"][did] = val
save_config(c)
clear_stats_cache()
mw.deckBrowser.refresh()
except: pass
else:
if hasattr(mw.deckBrowser, "_old_handler"):
mw.deckBrowser._old_handler(cmd)
def on_review_answered(reviewer, card, ease):
clear_stats_cache()
def cleanup_temp_deck_before_render(deck_browser, content):
"""
Remove o deck temporário ANTES da tela de baralhos ser renderizada.
Isso garante que as contagens dos baralhos originais sejam recalculadas corretamente.
"""
try:
did = mw.col.decks.id_for_name(TEMP_DECK_NAME)
if did:
# Remove o baralho temporário
mw.col.decks.remove([did])
# Força o agendador a resetar seu estado interno.
# Como isso acontece ANTES da renderização, a árvore de baralhos
# será construída com os dados corretos.
mw.col.sched.reset()
except Exception as e:
# É uma boa prática registrar erros caso algo inesperado aconteça
print(f"Pinned Decks: Error during temp deck cleanup: {e}")
gui_hooks.deck_browser_will_show_options_menu.append(on_options_menu)
gui_hooks.deck_browser_will_render_content.append(render_pinned)
gui_hooks.reviewer_did_answer_card.append(on_review_answered)
gui_hooks.deck_browser_will_render_content.append(cleanup_temp_deck_before_render)
if not hasattr(mw.deckBrowser, "_old_handler"):
mw.deckBrowser._old_handler = mw.deckBrowser._linkHandler
mw.deckBrowser._linkHandler = lambda url: handler(url)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment