Skip to content

Instantly share code, notes, and snippets.

@eros18123
Created February 13, 2026 14:52
Show Gist options
  • Select an option

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

Select an option

Save eros18123/8c712f0a351df804911fbbec86166388 to your computer and use it in GitHub Desktop.
estatisticas nos cards 1.2
# -*- coding: utf-8 -*-
import json
import os
import re
import csv
import webbrowser
import aqt # Importante para abrir o Browser
from aqt import mw, gui_hooks
from aqt.qt import *
from aqt.editor import Editor
from aqt.webview import AnkiWebView
from anki.utils import intTime, strip_html
from aqt.theme import theme_manager
import datetime
import math
ADDON_DIR = os.path.dirname(__file__)
CONFIG_FILE = os.path.join(ADDON_DIR, "user_config.json")
CORES = {
"MADURO": "#006400", "JOVEM": "#90EE90",
"APRENDENDO": "#FA8072", "LAPSO": "#A52A2A", "NOVO": "#DDDDDD",
"SUSPENSO": "#FFFF00"
}
LEGENDA_TEXTO = {
"MADURO": "Maduro", "JOVEM": "Jovem",
"APRENDENDO": "Aprendendo", "LAPSO": "Lapso", "NOVO": "Novo", "SUSPENSO": "Suspenso"
}
CORES_BOTOES = {
1: "#FF0000",
2: "#FFA500",
3: "#00FF00",
4: "#0000FF"
}
LEGENDA_BOTOES = {
1: "De Novo (Cliques)",
2: "Difícil (Cliques)",
3: "Bom (Cliques)",
4: "Fácil (Cliques)"
}
LIMITE_MADURO = 21
DEFAULT_CARDS_PER_LOAD = 50
def get_card_category_key(type_id, ivl, queue):
if queue == -1:
return "SUSPENSO"
if type_id == 0:
return "NOVO"
if type_id == 1:
return "APRENDENDO"
if type_id == 3:
return "LAPSO"
return "MADURO" if ivl >= LIMITE_MADURO else "JOVEM"
def format_due_date_string(card, col):
if card.queue == -1:
return "Suspenso"
if card.queue == 0:
return "Novo"
try:
return mw.col.sched.format_due_date(card.due)
except AttributeError:
try:
if card.due > col.sched.today:
diff_days = card.due - col.sched.today
if diff_days == 1: return "Amanhã"
elif diff_days > 1: return f"Em {diff_days} dias"
else: return "Hoje"
else: return "Hoje"
except Exception:
return f"Data: {card.due}"
return "N/A"
def fix_card_render(card, html):
audio_html = ""
for val in card.note().values():
sounds = re.findall(r"\[sound:(.*?)\]", val)
for s in sounds:
audio_html += f'<audio controls style="width:100%; height:30px; margin:5px 0;"><source src="{s}" type="audio/mpeg"></audio>'
html = re.sub(r"\[anki:play:.*?\]", "", html)
style_fix = """
<style>
body { font-family: sans-serif; padding: 15px; margin: 0; background-color: transparent; }
img { max-width: 100% !important; height: auto !important; }
#io-wrapper { position: relative !important; display: inline-block !important; margin: 0 auto !important; }
#io-overlay, svg { position: absolute !important; top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important; pointer-events: none; }
.io-mask, .io-box { display: block !important; visibility: visible !important; opacity: 1 !important; }
</style>"""
js_fix = "<script>if(window.anki && window.anki.imageOcclusion) window.anki.imageOcclusion.setup();</script>"
return f"{style_fix}{html}{audio_html}{js_fix}"
def clean_text_content(text):
# Remove HTML tags e caracteres especiais para exibição na tabela
text = strip_html(text)
text = text.replace("&nbsp;", " ").replace("\n", " ").strip()
return text
class PieChartWidget(QWidget):
def __init__(self, counts, colors, labels, parent=None):
super().__init__(parent)
self.counts = counts
self.colors = colors
self.labels = labels
self.setFixedSize(130, 130)
self.setMouseTracking(True)
self.center_x = 65
self.center_y = 65
def mouseMoveEvent(self, event):
pos = event.pos()
x = pos.x() - self.center_x
y = self.center_y - pos.y()
distance = (x**2 + y**2)**0.5
if distance > 60 or distance < 5:
QToolTip.hideText()
return
angle = math.atan2(y, x)
if angle < 0:
angle += 2 * math.pi
angle_deg = math.degrees(angle)
total = sum(self.counts.values())
if total == 0:
return
current_angle = 0
for key in self.counts.keys():
count = self.counts.get(key, 0)
if count == 0:
continue
slice_angle = 360 * count / total
end_angle = current_angle + slice_angle
if current_angle <= angle_deg < end_angle:
percentage = (count / total) * 100
tooltip_text = f"{self.labels[key]}\n{count:,} ({percentage:.1f}%)"
try:
QToolTip.showText(event.globalPosition().toPoint(), tooltip_text, self)
except AttributeError:
QToolTip.showText(event.globalPos(), tooltip_text, self)
return
current_angle = end_angle
QToolTip.hideText()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
total = sum(self.counts.values())
if total == 0:
return
rect = QRectF(5, 5, 120, 120)
start_angle = 0
for key in self.counts.keys():
count = self.counts.get(key, 0)
if count == 0:
continue
span_angle = int(360 * 16 * count / total)
painter.setBrush(QColor(self.colors[key]))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawPie(rect, start_angle, span_angle)
percentage = (count / total) * 100
if percentage >= 8:
mid_angle = start_angle + span_angle / 2
angle_rad = (mid_angle / 16) * (math.pi / 180)
text_x = 65 + 40 * math.cos(angle_rad)
text_y = 65 - 40 * math.sin(angle_rad)
painter.setPen(QColor("black"))
font = painter.font()
font.setBold(True)
font.setPointSize(8)
painter.setFont(font)
text = f"{percentage:.0f}%"
text_rect = painter.fontMetrics().boundingRect(text)
painter.drawText(int(text_x - text_rect.width()/2), int(text_y + text_rect.height()/4), text)
start_angle += span_angle
class SortOrderDialog(QDialog):
def __init__(self, current_order, parent):
super().__init__(parent)
self.setWindowTitle("Configurar Ordem de Prioridade")
self.resize(300, 400)
l = QVBoxLayout(self)
l.addWidget(QLabel("Arraste para definir qual categoria aparece primeiro:"))
self.list = QListWidget()
self.list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
for key in current_order:
it = QListWidgetItem(LEGENDA_TEXTO[key])
it.setData(Qt.ItemDataRole.UserRole, key)
it.setBackground(QColor(CORES[key]))
it.setForeground(QColor("white" if key in ["MADURO", "LAPSO"] else "black"))
self.list.addItem(it)
l.addWidget(self.list)
b = QPushButton("Aplicar Ordem"); b.clicked.connect(self.accept); l.addWidget(b)
def get_order(self):
return [self.list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list.count())]
class EditDialog(QDialog):
def __init__(self, note, parent):
super().__init__(parent)
self.setWindowTitle("Editar Card")
self.resize(800, 600)
self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMinMaxButtonsHint)
l = QVBoxLayout(self)
self.editor = Editor(mw, QWidget(self), self); self.editor.set_note(note)
l.addWidget(self.editor.widget)
b = QPushButton("Salvar"); b.clicked.connect(lambda: self.editor.saveNow(self.accept)); l.addWidget(b)
class SingleCardDialog(QDialog):
def __init__(self, cids, index, parent):
super().__init__(parent)
self.cids, self.index = cids, index
self.resize(1000, 850)
self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMinMaxButtonsHint)
l = QVBoxLayout(self)
self.web_f = AnkiWebView(self); self.web_b = AnkiWebView(self)
self.split = QSplitter(Qt.Orientation.Vertical); self.split.addWidget(self.web_f); self.split.addWidget(self.web_b)
l.addWidget(self.split)
nav = QHBoxLayout()
self.btn_p = QPushButton("<< Anterior"); self.btn_p.clicked.connect(self.prev)
self.btn_e = QPushButton("📝 Editar"); self.btn_e.clicked.connect(self.edit)
self.btn_n = QPushButton("Próximo >>"); self.btn_n.clicked.connect(self.next)
nav.addWidget(self.btn_p); nav.addStretch(); nav.addWidget(self.btn_e); nav.addStretch(); nav.addWidget(self.btn_n)
l.addLayout(nav)
self.load()
def load(self):
card = mw.col.get_card(self.cids[self.index])
self.setWindowTitle(f"Card {self.index+1} / {len(self.cids)}")
b_class = theme_manager.body_classes_for_card_ord(card.ord, theme_manager.night_mode)
q = fix_card_render(card, gui_hooks.card_will_show(card.question(), card, "preview"))
a = fix_card_render(card, gui_hooks.card_will_show(card.answer(), card, "preview"))
scripts = ["js/reviewer.js"]
self.web_f.stdHtml(q, css=["css/reviewer.css"], js=scripts, context=self)
self.web_f.eval(f"document.body.className='{b_class}';")
self.web_b.stdHtml(a, css=["css/reviewer.css"], js=scripts, context=self)
self.web_b.eval(f"document.body.className='{b_class}';")
def prev(self):
if self.index > 0: self.index -= 1; self.load()
def next(self):
if self.index < len(self.cids)-1: self.index += 1; self.load()
def edit(self):
c = mw.col.get_card(self.cids[self.index])
if EditDialog(c.note(), self).exec(): self.load()
class MainGridDialog(QDialog):
def __init__(self):
super().__init__(mw)
self.setWindowTitle("Visualizador de Deck em Tabela v2.0")
self.resize(1300, 850)
self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMinMaxButtonsHint)
self.setWindowState(Qt.WindowState.WindowMaximized)
self.config = {
"sort_order": ["MADURO", "JOVEM", "APRENDENDO", "LAPSO", "NOVO", "SUSPENSO"],
"last_deck_name": None,
"last_sort_index": 0,
"expanded_decks": {},
"cards_per_page": DEFAULT_CARDS_PER_LOAD,
"show_charts": True,
"color_by_sequence": False,
"sequence_threshold": 10,
"column_widths": [],
"splitter_sizes": [],
"show_front_column": False,
"show_back_column": False # Nova config
}
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r") as f: self.config.update(json.load(f))
except Exception: pass
self.all_data = []
self.filtered = []
self.active_cat = None
self.current_deck_name = self.config["last_deck_name"]
self.page_size = self.config["cards_per_page"]
self.current_page = 0
self.current_sort_col = -1
self.current_sort_reverse = True
self.setup_ui()
def setup_ui(self):
l = QVBoxLayout(self)
l.setSpacing(0)
l.setContentsMargins(5, 5, 5, 5)
# --- PESQUISA ---
search_layout = QHBoxLayout()
search_layout.setContentsMargins(0, 0, 0, 2)
self.search_bar = QLineEdit()
self.search_bar.setPlaceholderText("Pesquisar texto ou CID...")
self.search_bar.returnPressed.connect(self.re_filter)
search_layout.addWidget(QLabel("<b>Pesquisar:</b>"))
search_layout.addWidget(self.search_bar)
l.addLayout(search_layout)
# --- ESTATÍSTICAS E GRÁFICOS ---
self.stats_widget = QWidget()
stats_layout = QHBoxLayout(self.stats_widget)
stats_layout.setContentsMargins(0, 0, 0, 0)
stats_layout.setSpacing(5)
self.left_info = QVBoxLayout()
self.left_info.setSpacing(2)
self.lbl_avg_retention = QLabel("Retenção Média: --")
self.lbl_avg_retention.setStyleSheet("font-weight:bold; color:#007ACC; font-size:12px;")
self.left_info.addWidget(self.lbl_avg_retention)
self.btn_toggle_charts = QPushButton("📊 Ocultar Gráficos" if self.config["show_charts"] else "📊 Mostrar Gráficos")
self.btn_toggle_charts.setMaximumWidth(130)
self.btn_toggle_charts.clicked.connect(self.toggle_charts)
self.left_info.addWidget(self.btn_toggle_charts)
self.left_info.addStretch()
stats_layout.addLayout(self.left_info)
self.charts_container = QWidget()
charts_layout = QHBoxLayout(self.charts_container)
charts_layout.setContentsMargins(0, 0, 0, 0)
charts_layout.setSpacing(15)
self.pie_chart_categories = PieChartWidget({}, CORES, LEGENDA_TEXTO)
charts_layout.addWidget(self.pie_chart_categories)
self.pie_chart_buttons = PieChartWidget({}, CORES_BOTOES, LEGENDA_BOTOES)
charts_layout.addWidget(self.pie_chart_buttons)
stats_layout.addWidget(self.charts_container)
stats_layout.addStretch()
l.addWidget(self.stats_widget)
if not self.config["show_charts"]:
self.charts_container.hide()
self.stats_widget.setFixedHeight(45)
else:
self.stats_widget.setFixedHeight(140)
# --- FILTROS RÁPIDOS ---
top = QHBoxLayout()
top.setContentsMargins(0, 2, 0, 5)
self.btn_total = QPushButton("Total Cards: 0");
self.btn_total.clicked.connect(lambda: self.load_deck("all", update_last_deck=True))
self.btn_total.setStyleSheet("font-weight:bold; background:#007ACC; color:white; padding:3px 8px;")
top.addWidget(self.btn_total)
self.filter_btns = {}
filter_order = ["MADURO", "JOVEM", "APRENDENDO", "LAPSO", "NOVO", "SUSPENSO"]
for cat in filter_order:
btn = QPushButton(f"{LEGENDA_TEXTO[cat]}")
btn.setStyleSheet(f"background:{CORES[cat]}; color:{'white' if cat in ['MADURO', 'LAPSO'] else 'black'}; font-weight:bold; font-size:10px; padding:3px 6px;")
btn.clicked.connect(lambda checked, c=cat: self.apply_filter(c))
top.addWidget(btn)
self.filter_btns[cat] = btn
top.addStretch()
self.sort_combo = QComboBox()
self.sort_combo.addItems(["Prioridade (Padrão)", "Retenção", "Sequência"])
self.sort_combo.currentIndexChanged.connect(self.re_sort_combo)
top.addWidget(self.sort_combo)
btn_cfg = QPushButton("⚙ Prioridades"); btn_cfg.clicked.connect(self.open_priority_config); top.addWidget(btn_cfg)
# BOTÕES DE EXPORTAÇÃO
btn_export = QPushButton("📊 Exportar CSV"); btn_export.clicked.connect(self.export_to_csv); top.addWidget(btn_export)
btn_html = QPushButton("🌐 Abrir em HTML"); btn_html.clicked.connect(self.export_to_html); top.addWidget(btn_html)
l.addLayout(top)
# --- ÁREA DE DADOS (TREE + TABLE) ---
self.split = QSplitter(Qt.Orientation.Horizontal)
self.tree = QTreeWidget(); self.tree.setHeaderLabel("Baralhos"); self.tree.setFixedWidth(230)
self.tree.itemClicked.connect(lambda it: self.load_deck(it.data(0, Qt.ItemDataRole.UserRole), update_last_deck=True))
if self.config.get("splitter_sizes"):
self.tree.setFixedWidth(16777215)
self.tree.setMaximumWidth(16777215)
self.split.addWidget(self.tree)
right_w = QWidget()
right = QVBoxLayout(right_w)
right.setContentsMargins(0, 0, 0, 0)
self.table = QTableWidget()
# Aumentado para 11 colunas (Adicionado "Frente" e "Verso")
self.table.setColumnCount(11)
self.table.setHorizontalHeaderLabels(["Nº", "Frente", "Verso", "D.Novo", "Bom", "Difícil", "Fácil", "Revisões", "Sequência", "Retenção", "Próx. Revisão"])
self.table.verticalHeader().setVisible(False)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
self.table.horizontalHeader().setStretchLastSection(True)
# Carregar larguras salvas
saved_widths = self.config.get("column_widths", [])
if saved_widths and len(saved_widths) == self.table.columnCount():
for i, width in enumerate(saved_widths):
self.table.setColumnWidth(i, width)
else:
# Largura padrão para as colunas Frente e Verso
self.table.setColumnWidth(1, 200)
self.table.setColumnWidth(2, 200)
self.table.horizontalHeader().sectionClicked.connect(self.sort_by_header)
self.table.cellDoubleClicked.connect(self.open_card_dialog)
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu)
self.table.itemSelectionChanged.connect(self.update_selection_count)
right.addWidget(self.table)
nav_bottom = QHBoxLayout()
self.btn_prev = QPushButton("<< Anterior"); self.btn_prev.clicked.connect(self.prev_page); self.btn_prev.setEnabled(False)
nav_bottom.addWidget(self.btn_prev)
self.btn_next = QPushButton("Próximo >>"); self.btn_next.clicked.connect(self.next_page); self.btn_next.setEnabled(False)
nav_bottom.addWidget(self.btn_next)
self.lbl_status = QLabel("0-0 de 0 Cards"); self.lbl_status.setAlignment(Qt.AlignmentFlag.AlignCenter)
nav_bottom.addWidget(self.lbl_status)
self.lbl_selection = QLabel(""); self.lbl_selection.setStyleSheet("font-weight:bold; color:#007ACC;")
nav_bottom.addWidget(self.lbl_selection)
nav_bottom.addStretch()
# --- CONTROLES DE COR POR SEQUÊNCIA ---
nav_bottom.addWidget(QLabel("|"))
self.cb_color_seq = QCheckBox("Colorir por Seq.")
self.cb_color_seq.setChecked(self.config.get("color_by_sequence", False))
self.cb_color_seq.stateChanged.connect(self.draw)
nav_bottom.addWidget(self.cb_color_seq)
nav_bottom.addWidget(QLabel("Meta Seq.:"))
self.sb_seq_threshold = QSpinBox()
self.sb_seq_threshold.setRange(1, 999)
self.sb_seq_threshold.setValue(self.config.get("sequence_threshold", 10))
self.sb_seq_threshold.valueChanged.connect(self.draw)
nav_bottom.addWidget(self.sb_seq_threshold)
self.lbl_seq_stats = QLabel("")
self.lbl_seq_stats.setStyleSheet("color: #006400; font-weight: bold; margin-left: 5px;")
nav_bottom.addWidget(self.lbl_seq_stats)
nav_bottom.addWidget(QLabel("|"))
# --- CHECKBOX MOSTRAR FRENTE E VERSO ---
self.cb_show_front = QCheckBox("Mostrar Frente")
self.cb_show_front.setChecked(self.config.get("show_front_column", False))
self.cb_show_front.stateChanged.connect(self.draw)
nav_bottom.addWidget(self.cb_show_front)
self.cb_show_back = QCheckBox("Mostrar Verso")
self.cb_show_back.setChecked(self.config.get("show_back_column", False))
self.cb_show_back.stateChanged.connect(self.draw)
nav_bottom.addWidget(self.cb_show_back)
nav_bottom.addWidget(QLabel("|"))
# --------------------------------------------
nav_bottom.addWidget(QLabel("Cards/Pág:"));
self.spin_box = QSpinBox(); self.spin_box.setRange(10, 1000); self.spin_box.setValue(self.page_size)
self.spin_box.valueChanged.connect(self.change_page_size)
nav_bottom.addWidget(self.spin_box)
right.addLayout(nav_bottom)
split_widget = right_w
self.split.addWidget(split_widget)
if self.config.get("splitter_sizes"):
self.split.setSizes(self.config["splitter_sizes"])
l.addWidget(self.split)
self.tree.itemExpanded.connect(self._save_tree_expansion_state)
self.tree.itemCollapsed.connect(self._save_tree_expansion_state)
self.load_tree()
def toggle_charts(self):
self.config["show_charts"] = not self.config["show_charts"]
if self.config["show_charts"]:
self.charts_container.show()
self.stats_widget.setFixedHeight(140)
self.btn_toggle_charts.setText("📊 Ocultar Gráficos")
else:
self.charts_container.hide()
self.stats_widget.setFixedHeight(45)
self.btn_toggle_charts.setText("📊 Mostrar Gráficos")
def update_selection_count(self):
selected_rows = self.table.selectionModel().selectedRows()
count = len(selected_rows)
self.lbl_selection.setText(f"{count} card(s) selecionado(s)" if count > 0 else "")
def export_to_csv(self):
if not self.filtered: return
desktop = os.path.join(os.path.expanduser("~"), "Desktop")
base_name = "anki_export"
extension = ".csv"
file_path = os.path.join(desktop, f"{base_name}{extension}")
counter = 2
while os.path.exists(file_path):
file_path = os.path.join(desktop, f"{base_name}{counter}{extension}")
counter += 1
try:
with open(file_path, 'w', newline='', encoding='utf-8-sig') as csvfile:
csvfile.write("sep=;\n")
writer = csv.writer(csvfile, delimiter=';')
writer.writerow(["CID", "Frente", "Verso", "Categoria", "D.Novo", "Difícil", "Bom", "Fácil", "Revisões", "Sequência", "Retenção (%)", "Próx. Revisão"])
for item in self.filtered:
s = item['s']
writer.writerow([f'="{item["cid"]}"', item['front_text'], item['back_text'], LEGENDA_TEXTO[item['cat']], s[1], s[2], s[3], s[4], item['total_rev'], item['seq'], f"{item['r']:.1f}".replace('.', ','), item['due_date']])
QMessageBox.information(self, "Sucesso", f"Exportado para: {file_path}")
except Exception as e: QMessageBox.warning(self, "Erro", f"Erro ao exportar: {str(e)}")
def export_to_html(self):
if not self.filtered: return
desktop = os.path.join(os.path.expanduser("~"), "Desktop")
file_path = os.path.join(desktop, "visualizacao_anki.html")
data_relatorio = datetime.datetime.now().strftime("%d/%m/%Y")
color_by_seq = self.cb_color_seq.isChecked()
seq_threshold = self.sb_seq_threshold.value()
show_front = self.cb_show_front.isChecked()
show_back = self.cb_show_back.isChecked()
header_front = "<th>Frente</th>" if show_front else ""
header_back = "<th>Verso</th>" if show_back else ""
rows_html = ""
for i, item in enumerate(self.filtered):
cat = item['cat']
if color_by_seq:
if item['seq'] >= seq_threshold:
bg = "#90EE90"
txt = "black"
else:
bg = "#FF9999"
txt = "black"
else:
bg = CORES[cat]
txt = "white" if cat in ["MADURO", "LAPSO"] else "black"
s = item['s']
col_front = f"<td>{item['front_text']}</td>" if show_front else ""
col_back = f"<td>{item['back_text']}</td>" if show_back else ""
# REMOVIDO CID E CATEGORIA DAQUI CONFORME SOLICITADO
rows_html += f"""
<tr style="background-color: {bg}; color: {txt};">
<td>{i+1}</td>
{col_front}
{col_back}
<td>{s[1]}</td>
<td>{s[3]}</td>
<td>{s[2]}</td>
<td>{s[4]}</td>
<td>{item['total_rev']}</td>
<td>{item['seq']}</td>
<td>{item['r']:.1f}%</td>
<td>{item['due_date']}</td>
</tr>"""
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Relatório de Cards Anki</title>
<style>
body {{ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; background-color: #f4f4f9; }}
h2 {{ color: #333; text-align: center; margin-bottom: 5px; }}
.data-info {{ text-align: center; color: #666; font-size: 14px; margin-bottom: 20px; }}
table {{ width: 100%; border-collapse: collapse; box-shadow: 0 2px 15px rgba(0,0,0,0.1); background: white; }}
th {{ background-color: #007ACC; color: white; padding: 12px; text-align: center; font-size: 14px; }}
td {{ padding: 8px; text-align: center; border: 1px solid rgba(0,0,0,0.05); font-size: 13px; font-weight: 500; }}
tr:hover {{ filter: brightness(90%); }}
.container {{ max-width: 1200px; margin: auto; }}
</style>
</head>
<body>
<div class="container">
<h2>Relatório de Cards - {self.current_deck_name or "Todos os Baralhos"}</h2>
<div class="data-info">Relatório gerado em: <b>{data_relatorio}</b></div>
<table>
<thead>
<tr>
<th>Nº</th>
{header_front}
{header_back}
<th>D.Novo</th>
<th>Bom</th>
<th>Difícil</th>
<th>Fácil</th>
<th>Revisões</th>
<th>Sequência</th>
<th>Retenção</th>
<th>Próx. Revisão</th>
</tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
</div>
</body>
</html>
"""
try:
with open(file_path, "w", encoding="utf-8") as f:
f.write(html_content)
webbrowser.open(f"file:///{file_path}")
except Exception as e:
QMessageBox.warning(self, "Erro", f"Erro ao gerar HTML: {str(e)}")
def show_context_menu(self, position):
selected_rows = self.table.selectionModel().selectedRows()
if not selected_rows: return
start_index = self.current_page * self.page_size
selected_cids = []
for index in selected_rows:
actual_index = start_index + index.row()
if actual_index < len(self.filtered): selected_cids.append(self.filtered[actual_index]['cid'])
if not selected_cids: return
all_suspended = True
all_active = True
for cid in selected_cids:
card = mw.col.get_card(cid)
if card.queue == -1: all_active = False
else: all_suspended = False
menu = QMenu(self)
cid_string = "cid:" + ",".join(str(c) for c in selected_cids)
action_copy = menu.addAction(f"📋 Copiar {len(selected_cids)} CID(s)")
action_copy.triggered.connect(lambda: QApplication.clipboard().setText(cid_string))
action_browser = menu.addAction("🔍 Abrir no Painel do Anki")
action_browser.triggered.connect(lambda: self.open_in_browser(selected_cids))
menu.addSeparator()
if all_suspended:
menu.addAction(f"✓ Reativar {len(selected_cids)} Card(s)").triggered.connect(lambda: self.unsuspend_cards(selected_cids))
elif all_active:
menu.addAction(f"⏸ Suspender {len(selected_cids)} Card(s)").triggered.connect(lambda: self.suspend_cards(selected_cids))
else:
menu.addAction(f"⏸ Suspender {len(selected_cids)} Card(s)").triggered.connect(lambda: self.suspend_cards(selected_cids))
menu.addAction(f"✓ Reativar {len(selected_cids)} Card(s)").triggered.connect(lambda: self.unsuspend_cards(selected_cids))
menu.exec(self.table.viewport().mapToGlobal(position))
def open_in_browser(self, cids):
query = "cid:" + ",".join(str(c) for c in cids)
browser = aqt.dialogs.open("Browser", mw)
if hasattr(browser, "search_for"):
browser.search_for(query)
elif hasattr(browser, "setFilter"):
browser.setFilter(query)
else:
browser.search(query)
def suspend_cards(self, cids):
mw.col.sched.suspend_cards(cids); mw.col.save()
self.load_deck(self.current_deck_name or "all", update_last_deck=False)
def unsuspend_cards(self, cids):
mw.col.sched.unsuspend_cards(cids); mw.col.save()
self.load_deck(self.current_deck_name or "all", update_last_deck=False)
def sort_by_header(self, logical_index):
if self.current_sort_col == logical_index: self.current_sort_reverse = not self.current_sort_reverse
else: self.current_sort_col = logical_index; self.current_sort_reverse = True
# Mapa de índices atualizado com a nova coluna "Verso" no índice 2
key_map = {
0: lambda x: x['cid'],
1: lambda x: x['front_text'],
2: lambda x: x['back_text'], # Nova coluna
3: lambda x: x['s'][1],
4: lambda x: x['s'][3],
5: lambda x: x['s'][2],
6: lambda x: x['s'][4],
7: lambda x: x['total_rev'],
8: lambda x: x['seq'],
9: lambda x: x['r'],
10: lambda x: x['raw_due']
}
if logical_index in key_map:
self.filtered.sort(key=key_map[logical_index], reverse=self.current_sort_reverse)
self.current_page = 0
self.draw()
def open_card_dialog(self, row, column):
actual_index = (self.current_page * self.page_size) + row
if actual_index < len(self.filtered):
SingleCardDialog([d['cid'] for d in self.filtered], actual_index, self).exec()
def open_priority_config(self):
d = SortOrderDialog(self.config["sort_order"], self)
if d.exec():
self.config["sort_order"] = d.get_order()
self.re_sort_combo()
def change_page_size(self, value):
self.page_size = value; self.config["cards_per_page"] = value
self.current_page = 0; self.draw()
def prev_page(self):
if self.current_page > 0: self.current_page -= 1; self.draw()
def next_page(self):
if (self.current_page + 1) * self.page_size < len(self.filtered): self.current_page += 1; self.draw()
def load_tree(self):
item_to_select = None
try: deck_tree = mw.col.decks.deck_tree()
except: deck_tree = mw.col.decks.child_nodes(mw.col.decks.get_current_id())
def add(parent_item, nodes):
nonlocal item_to_select
for node in nodes:
it = QTreeWidgetItem(parent_item, [node.name])
it.setData(0, Qt.ItemDataRole.UserRole, node.deck_id)
it.setData(0, Qt.ItemDataRole.UserRole + 1, node.name)
if node.name in self.config["expanded_decks"] and self.config["expanded_decks"][node.name]: it.setExpanded(True)
if node.name == self.current_deck_name: item_to_select = it
if node.children: add(it, node.children)
self.tree.clear()
add(self.tree.invisibleRootItem(), deck_tree.children)
if item_to_select:
self.tree.setCurrentItem(item_to_select); self.tree.scrollToItem(item_to_select)
self.load_deck(self.current_deck_name, update_last_deck=False)
def _save_tree_expansion_state(self, item=None):
self.config["expanded_decks"] = {}
def save_state(parent):
for i in range(parent.childCount()):
child = parent.child(i)
name = child.data(0, Qt.ItemDataRole.UserRole + 1)
if name: self.config["expanded_decks"][name] = child.isExpanded()
save_state(child)
save_state(self.tree.invisibleRootItem())
def load_deck(self, deck_identifier, update_last_deck=True):
if deck_identifier == "all":
deck_name = "all"; search_query = ""
elif isinstance(deck_identifier, int):
try: deck = mw.col.decks.get(deck_identifier); deck_name = deck['name']; search_query = f'deck:"{deck_name}"'
except: deck_name = None; search_query = ""
else: deck_name = deck_identifier; search_query = f'deck:"{deck_name}"'
if update_last_deck: self.current_deck_name = deck_name
search_text = self.search_bar.text().strip()
if search_text:
if all(c.isdigit() or c == ',' for c in search_text):
q_part = f' (cid:{search_text} OR note:*{search_text}*)'
else:
q_part = f' (note:*{search_text}* OR "{search_text}")'
search_query = (search_query + q_part) if search_query else q_part
cids = mw.col.find_cards(search_query.strip())
if not cids:
self.all_data = []; self.filtered = []; self.clear_table()
self.lbl_status.setText("0 Cards encontrados"); return
c_str = ",".join(map(str, cids))
query = f"""
SELECT c.id, c.type, c.ivl, c.due, c.reps, c.queue, n.flds
FROM cards c
JOIN notes n ON c.nid = n.id
WHERE c.id IN ({c_str})
"""
cards_data = mw.col.db.all(query)
stats = mw.col.db.all(f"SELECT cid, ease, count() FROM revlog WHERE cid IN ({c_str}) GROUP BY cid, ease")
s_map = {}
for cid, ease, count in stats:
if cid not in s_map: s_map[cid] = {1:0, 2:0, 3:0, 4:0}
if ease in [1, 2, 3, 4]: s_map[cid][ease] = count
revlog_map = {}
for cid, ease in mw.col.db.all(f"SELECT cid, ease FROM revlog WHERE cid IN ({c_str}) ORDER BY id DESC"):
if cid not in revlog_map: revlog_map[cid] = []
revlog_map[cid].append(ease)
self.all_data = []
counts = {k: 0 for k in CORES.keys()}
button_counts = {1: 0, 2: 0, 3: 0, 4: 0}
total_retention = 0; cards_with_reviews = 0
for cid, t, ivl, due, reps, queue, flds in cards_data:
card = mw.col.get_card(cid)
current_seq = 0
if cid in revlog_map:
for ease in revlog_map[cid]:
if ease in (3, 4): current_seq += 1
elif ease == 1: break
s = s_map.get(cid, {1:0, 2:0, 3:0, 4:0})
for ease, count in s.items(): button_counts[ease] += count
cat = get_card_category_key(t, ivl, queue)
counts[cat] += 1
tot_s = sum(s.values())
ret = ((tot_s - s[1]) / tot_s * 100) if tot_s > 0 else 0
if tot_s > 0: total_retention += ret; cards_with_reviews += 1
# Extrair Frente e Verso
fields = flds.split('\x1f')
front_raw = fields[0] if fields else ""
back_raw = fields[1] if len(fields) > 1 else ""
front_clean = clean_text_content(front_raw)
back_clean = clean_text_content(back_raw)
self.all_data.append({
'cid': cid, 'cat': cat, 's': s, 'r': ret, 'due_date': format_due_date_string(card, mw.col),
'raw_due': due, 'total_rev': reps, 'seq': current_seq,
'front_text': front_clean,
'back_text': back_clean
})
self.lbl_avg_retention.setText(f"Retenção Média: {(total_retention/cards_with_reviews if cards_with_reviews > 0 else 0):.1f}%")
self.pie_chart_categories.counts = counts; self.pie_chart_categories.update()
self.pie_chart_buttons.counts = button_counts; self.pie_chart_buttons.update()
self.btn_total.setText(f"Total Cards: {len(self.all_data)}")
for cat, btn in self.filter_btns.items(): btn.setText(f"{LEGENDA_TEXTO[cat]} ({counts[cat]})")
self.re_sort_combo()
def re_filter(self): self.load_deck(self.current_deck_name or "all", update_last_deck=False)
def apply_filter(self, cat):
self.active_cat = cat
self.filtered = [d for d in self.all_data if cat is None or d['cat'] == cat]
self.current_page = 0
self.draw()
def re_sort_combo(self):
m = self.sort_combo.currentIndex()
if m == 1: self.all_data.sort(key=lambda x: x['r'])
elif m == 2: self.all_data.sort(key=lambda x: x['seq'], reverse=True)
else:
order = {k: i for i, k in enumerate(self.config["sort_order"])}
self.all_data.sort(key=lambda x: (order.get(x['cat'], 99), x['cid']))
self.apply_filter(self.active_cat)
def clear_table(self): self.table.setRowCount(0)
def draw(self):
self.clear_table()
if not self.filtered:
self.lbl_status.setText("0 Filtrados"); self.btn_prev.setEnabled(False); self.btn_next.setEnabled(False)
self.lbl_seq_stats.setText("")
return
total = len(self.filtered); start = self.current_page * self.page_size; end = min(start + self.page_size, total)
self.table.setRowCount(end - start)
color_by_seq = self.cb_color_seq.isChecked()
seq_threshold = self.sb_seq_threshold.value()
show_front = self.cb_show_front.isChecked()
show_back = self.cb_show_back.isChecked()
# Ocultar ou mostrar colunas Frente (1) e Verso (2)
self.table.setColumnHidden(1, not show_front)
self.table.setColumnHidden(2, not show_back)
count_ge = 0
count_lt = 0
for item in self.filtered:
if item['seq'] >= seq_threshold:
count_ge += 1
else:
count_lt += 1
self.lbl_seq_stats.setText(f"[ {count_ge} ≥ {seq_threshold} | {count_lt} < {seq_threshold} ]")
for i in range(start, end):
row = i - start; item_data = self.filtered[i]; cat = item_data['cat']
if color_by_seq:
if item_data['seq'] >= seq_threshold:
bg = QColor("#90EE90") # Verde Claro
txt = QColor("black")
else:
bg = QColor("#FF9999") # Vermelho Claro
txt = QColor("black")
else:
bg = QColor(CORES[cat])
txt = QColor("white" if cat in ["MADURO", "LAPSO"] else "black")
s = item_data['s']
# Lista de colunas atualizada (11 itens)
cols = [
str(i + 1),
item_data['front_text'],
item_data['back_text'],
str(s[1]),
str(s[3]),
str(s[2]),
str(s[4]),
str(item_data['total_rev']),
str(item_data['seq']),
f"{item_data['r']:.0f}%",
item_data['due_date']
]
for c_idx, val in enumerate(cols):
it = QTableWidgetItem(val)
it.setBackground(bg)
it.setForeground(txt)
# Alinhamento: Esquerda para texto (Frente/Verso), Centro para números
if c_idx in [1, 2]:
it.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
it.setToolTip(val)
else:
it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, c_idx, it)
self.table.setRowHeight(row, 26)
self.lbl_status.setText(f"{start + 1}-{end} de {total} Cards")
self.btn_prev.setEnabled(self.current_page > 0); self.btn_next.setEnabled(end < total)
def closeEvent(self, event):
self._save_tree_expansion_state()
self.config["last_deck_name"] = self.current_deck_name
self.config["color_by_sequence"] = self.cb_color_seq.isChecked()
self.config["sequence_threshold"] = self.sb_seq_threshold.value()
self.config["show_front_column"] = self.cb_show_front.isChecked()
self.config["show_back_column"] = self.cb_show_back.isChecked() # Salvar estado do checkbox
self.config["splitter_sizes"] = self.split.sizes()
self.config["column_widths"] = [self.table.columnWidth(i) for i in range(self.table.columnCount())]
with open(CONFIG_FILE, "w") as f: json.dump(self.config, f, indent=4)
event.accept()
def start_grid_viewer():
if not hasattr(mw, "grid_viewer_v20") or not mw.grid_viewer_v20.isVisible():
mw.grid_viewer_v20 = MainGridDialog(); mw.grid_viewer_v20.show()
else: mw.grid_viewer_v20.raise_(); mw.grid_viewer_v20.activateWindow()
if hasattr(mw, "custom_grid_action"):
try: mw.form.menuTools.removeAction(mw.custom_grid_action)
except: pass
action = QAction("Visualizador de Deck em Tabela v2.0", mw)
action.triggered.connect(start_grid_viewer)
mw.form.menuTools.addAction(action)
mw.custom_grid_action = action
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment