Created
February 13, 2026 14:52
-
-
Save eros18123/8c712f0a351df804911fbbec86166388 to your computer and use it in GitHub Desktop.
estatisticas nos cards 1.2
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 | |
| 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(" ", " ").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