Created
February 2, 2026 11:10
-
-
Save eros18123/a42327926dc56fbbccbeb6ef2aabc94e to your computer and use it in GitHub Desktop.
estatisticas nos cards
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # -*- coding: utf-8 -*- | |
| import json | |
| import os | |
| import re | |
| import csv | |
| import webbrowser # Novo import para abrir o navegador | |
| 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 | |
| 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}" | |
| 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.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 | |
| } | |
| 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 nos cards...") | |
| 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) --- | |
| 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)) | |
| split.addWidget(self.tree) | |
| right_w = QWidget() | |
| right = QVBoxLayout(right_w) | |
| right.setContentsMargins(0, 0, 0, 0) | |
| self.table = QTableWidget() | |
| self.table.setColumnCount(9) | |
| self.table.setHorizontalHeaderLabels(["Nº", "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.Stretch) | |
| 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() | |
| nav_bottom.addWidget(QLabel("Cards/Pág:")); | |
| self.spin_box = QSpinBox(); self.spin_box.setRange(10, 500); 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.addWidget(right_w) | |
| l.addWidget(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", "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"]}"', 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") | |
| # Captura a data atual formatada | |
| data_relatorio = datetime.datetime.now().strftime("%d/%m/%Y") | |
| # Gerar linhas da tabela | |
| rows_html = "" | |
| for i, item in enumerate(self.filtered): | |
| cat = item['cat'] | |
| bg = CORES[cat] | |
| txt = "white" if cat in ["MADURO", "LAPSO"] else "black" | |
| s = item['s'] | |
| rows_html += f""" | |
| <tr style="background-color: {bg}; color: {txt};"> | |
| <td>{i+1}</td> | |
| <td>{item['cid']}</td> | |
| <td>{LEGENDA_TEXTO[cat]}</td> | |
| <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> | |
| <th>CID</th> | |
| <th>Categoria</th> | |
| <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) | |
| action_copy = menu.addAction(f"📋 Copiar {len(selected_cids)} CID(s)") | |
| action_copy.triggered.connect(lambda: QApplication.clipboard().setText("\n".join(str(c) for c in 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 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 | |
| key_map = {0: lambda x: x['cid'], 1: lambda x: x['s'][1], 2: lambda x: x['s'][3], 3: lambda x: x['s'][2], | |
| 4: lambda x: x['s'][4], 5: lambda x: x['total_rev'], 6: lambda x: x['seq'], 7: lambda x: x['r'], 8: 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: | |
| 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)) | |
| cards_data = mw.col.db.all(f"SELECT id, type, ivl, due, reps, queue FROM cards WHERE id IN ({c_str})") | |
| 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 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 | |
| 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, | |
| }) | |
| 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) | |
| return | |
| total = len(self.filtered); start = self.current_page * self.page_size; end = min(start + self.page_size, total) | |
| self.table.setRowCount(end - start) | |
| for i in range(start, end): | |
| row = i - start; item_data = self.filtered[i]; cat = item_data['cat'] | |
| bg = QColor(CORES[cat]); txt = QColor("white" if cat in ["MADURO", "LAPSO"] else "black") | |
| s = item_data['s'] | |
| cols = [str(i + 1), 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); 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 | |
| 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