Created
February 12, 2026 15:44
-
-
Save eros18123/2f8f54ccf01c415dd79b3c8128c8d816 to your computer and use it in GitHub Desktop.
Vinculador de Link e Maps 3
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 re | |
| import urllib.parse | |
| import os | |
| import aqt | |
| from aqt import mw | |
| from aqt.qt import * | |
| from aqt.gui_hooks import card_will_show, webview_did_receive_js_message | |
| from anki.utils import stripHTML | |
| import math | |
| import random | |
| # --- Gerenciamento de Configuração via Arquivo --- | |
| CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") | |
| def get_config(): | |
| default_conf = { | |
| "mode": "asterisk", | |
| "result_limit": 10, | |
| "output_format": "html", | |
| "show_map": True, | |
| "auto_reveal_target": True, # Revela o alvo | |
| "reveal_all_clozes": False # <--- NOVA OPÇÃO: Revela tudo | |
| } | |
| if os.path.exists(CONFIG_PATH): | |
| try: | |
| with open(CONFIG_PATH, "r", encoding="utf-8") as f: | |
| conf = json.load(f) | |
| for k, v in default_conf.items(): | |
| conf.setdefault(k, v) | |
| return conf | |
| except: pass | |
| return default_conf | |
| def save_config_to_file(conf): | |
| with open(CONFIG_PATH, "w", encoding="utf-8") as f: | |
| json.dump(conf, f, indent=4) | |
| # --- Interface de Configuração --- | |
| class WordSearchConfigDialog(QDialog): | |
| def __init__(self, parent): | |
| super().__init__(parent) | |
| self.setWindowTitle("Configurações de Busca Avançada") | |
| self.setMinimumWidth(750) | |
| layout = QVBoxLayout() | |
| layout.addWidget(QLabel("<h2>Configurações do Add-on</h2>")) | |
| self.conf = get_config() | |
| # Modo | |
| mode_group = QGroupBox("Modo de Interação") | |
| mode_layout = QVBoxLayout() | |
| self.radio_search = QRadioButton("Apenas Pesquisar (Temporário)") | |
| self.radio_asterisk = QRadioButton("Adicionar Asterisco (Persistente / Salva na Nota)") | |
| if self.conf["mode"] == "search": self.radio_search.setChecked(True) | |
| else: self.radio_asterisk.setChecked(True) | |
| mode_layout.addWidget(self.radio_search) | |
| mode_layout.addWidget(self.radio_asterisk) | |
| mode_group.setLayout(mode_layout) | |
| layout.addWidget(mode_group) | |
| # Visualização | |
| view_group = QGroupBox("Visualização e Saída") | |
| view_layout = QVBoxLayout() | |
| self.check_show_map = QCheckBox("Mostrar Mapa Mental (Hubs) no topo") | |
| self.check_show_map.setChecked(self.conf.get("show_map", True)) | |
| view_layout.addWidget(self.check_show_map) | |
| # Opções de Revelação (Mutuamente Exclusivas) | |
| self.reveal_button_group = QButtonGroup(self) # Criar um grupo de botões | |
| self.reveal_button_group.setExclusive(True) # Definir como exclusivo | |
| self.radio_reveal_target = QRadioButton("Revelar APENAS o Cloze da palavra buscada") | |
| self.radio_reveal_target.setToolTip("Abre somente o [...] que contém a palavra que você clicou.") | |
| # Lógica de inicialização: se auto_reveal_target é True E reveal_all_clozes é False | |
| self.radio_reveal_target.setChecked(self.conf.get("auto_reveal_target", True) and not self.conf.get("reveal_all_clozes", False)) | |
| view_layout.addWidget(self.radio_reveal_target) | |
| self.reveal_button_group.addButton(self.radio_reveal_target) | |
| self.radio_reveal_all = QRadioButton("Revelar TODOS os clozes (Mostra a frase completa)") | |
| self.radio_reveal_all.setToolTip("Se marcado, todos os campos [...] aparecerão abertos.") | |
| self.radio_reveal_all.setChecked(self.conf.get("reveal_all_clozes", False)) | |
| view_layout.addWidget(self.radio_reveal_all) | |
| self.reveal_button_group.addButton(self.radio_reveal_all) | |
| # Adicionar um radio button para "Não revelar nenhum" para cobrir o caso onde nenhum é selecionado | |
| self.radio_reveal_none = QRadioButton("Não revelar nenhum cloze automaticamente") | |
| self.radio_reveal_none.setToolTip("Nenhum cloze será revelado automaticamente. Você precisará clicar para revelá-los.") | |
| # Se nem "reveal_target" nem "reveal_all" estiverem marcados, então "reveal_none" deve estar marcado | |
| if not self.conf.get("auto_reveal_target", False) and not self.conf.get("reveal_all_clozes", False): | |
| self.radio_reveal_none.setChecked(True) | |
| view_layout.addWidget(self.radio_reveal_none) | |
| self.reveal_button_group.addButton(self.radio_reveal_none) | |
| # ------------------ | |
| self.radio_html = QRadioButton("HTML Renderizado (Padrão)") | |
| self.radio_json = QRadioButton("JSON Bruto (Desenvolvedor)") | |
| if self.conf.get("output_format", "html") == "html": self.radio_html.setChecked(True) | |
| else: self.radio_json.setChecked(True) | |
| view_layout.addWidget(self.radio_html) | |
| view_layout.addWidget(self.radio_json) | |
| view_group.setLayout(view_layout) | |
| layout.addWidget(view_group) | |
| # Limite | |
| limit_layout = QHBoxLayout() | |
| limit_layout.addWidget(QLabel("Limite de cards por círculo/lista:")) | |
| self.limit_sb = QSpinBox() | |
| self.limit_sb.setRange(1, 500) | |
| self.limit_sb.setValue(self.conf["result_limit"]) | |
| limit_layout.addWidget(self.limit_sb) | |
| layout.addLayout(limit_layout) | |
| btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) | |
| btns.accepted.connect(self.save_config) | |
| btns.rejected.connect(self.reject) | |
| layout.addWidget(btns) | |
| self.setLayout(layout) | |
| def save_config(self): | |
| new_conf = { | |
| "mode": "search" if self.radio_search.isChecked() else "asterisk", | |
| "output_format": "html" if self.radio_html.isChecked() else "json", | |
| "show_map": self.check_show_map.isChecked(), | |
| # Salva as novas opções de revelação com base nos radio buttons | |
| "auto_reveal_target": self.radio_reveal_target.isChecked(), | |
| "reveal_all_clozes": self.radio_reveal_all.isChecked(), | |
| "result_limit": self.limit_sb.value() | |
| } | |
| save_config_to_file(new_conf) | |
| if mw.reviewer.card: | |
| mw.reviewer.web.eval("document.getElementById('related-results-wrapper').innerHTML = ''; pycmd('check_auto_search');") | |
| self.accept() | |
| def setup_menu(): | |
| action = QAction("Configurar Busca de Cards", mw) | |
| action.triggered.connect(lambda: WordSearchConfigDialog(mw).exec()) | |
| mw.form.menuTools.addAction(action) | |
| setup_menu() | |
| # --- Script JS e Estilos --- | |
| JS_HANDLER = """ | |
| <script> | |
| (function() { | |
| const existingWrapper = document.getElementById('related-results-wrapper'); | |
| if (existingWrapper) existingWrapper.remove(); | |
| function wrapWords(node) { | |
| if (node.nodeType === 3) { | |
| const text = node.nodeValue; | |
| const regex = /(\\[sound:[^\\]]+\\]|<[^>]+>)|([a-zA-ZÀ-ÿ0-9:_.-]{2,}\\*?)/g; | |
| const fragment = document.createDocumentFragment(); | |
| let lastIndex = 0; | |
| let match; | |
| while ((match = regex.exec(text)) !== null) { | |
| if (match.index > lastIndex) fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index))); | |
| if (match[1]) { | |
| fragment.appendChild(document.createTextNode(match[1])); | |
| } else if (match[2]) { | |
| const word = match[2]; | |
| const isMarked = word.endsWith('*'); | |
| const cleanWord = isMarked ? word.slice(0, -1) : word; | |
| const span = document.createElement('span'); | |
| span.className = 'anki-clickable-word' + (isMarked ? ' word-is-linked' : ''); | |
| span.setAttribute('data-word', cleanWord); | |
| span.innerText = word; // Exibe a palavra com ou sem asterisco | |
| fragment.appendChild(span); | |
| } | |
| lastIndex = regex.lastIndex; | |
| } | |
| if (lastIndex < text.length) fragment.appendChild(document.createTextNode(text.substring(lastIndex))); | |
| node.parentNode.replaceChild(fragment, node); | |
| } else if (node.nodeType === 1) { | |
| const ignored = ['SCRIPT', 'STYLE', 'BUTTON', 'A', 'AUDIO', 'VIDEO', 'SVG', 'IMG', 'CANVAS', 'TEXTAREA']; | |
| if (ignored.includes(node.tagName)) return; | |
| for (let i = node.childNodes.length - 1; i >= 0; i--) wrapWords(node.childNodes[i]); | |
| } | |
| } | |
| window.addEventListener('click', function(e) { | |
| if (e.target.classList.contains('anki-clickable-word')) { | |
| e.stopImmediatePropagation(); | |
| const word = e.target.getAttribute('data-word'); | |
| const isAnd = e.ctrlKey || e.metaKey; | |
| const allSpans = document.querySelectorAll(`.anki-clickable-word[data-word="${word}"]`); | |
| // Determina se a palavra será marcada ou desmarcada | |
| const willBeLinked = !allSpans[0].classList.contains('word-is-linked'); | |
| allSpans.forEach(span => { | |
| span.classList.toggle('word-is-linked', willBeLinked); | |
| // Atualiza o texto do span para refletir o asterisco | |
| span.innerText = willBeLinked ? word + '*' : word; | |
| }); | |
| const marked = Array.from(document.querySelectorAll('.word-is-linked')).map(el => el.getAttribute('data-word')); | |
| pycmd("word_search_action:" + JSON.stringify({ action: isAnd ? "search_and" : "search_or", toggle_word: word, will_be_linked: willBeLinked, all_marked: marked })); | |
| } | |
| }, true); | |
| // Função global para alternar clozes sem quebrar HTML | |
| window.toggleCloze = function(el, content) { | |
| event.stopPropagation(); | |
| if (el.innerText === '[...]') { | |
| el.innerText = content; | |
| el.classList.add('cloze-revealed'); | |
| } else { | |
| el.innerText = '[...]'; | |
| el.classList.remove('cloze-revealed'); | |
| } | |
| }; | |
| function escapeHTML(str) { | |
| return str.replace(/[&<>"']/g, function(m) { | |
| return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]; | |
| }); | |
| } | |
| window.onAnkiSearchResults = function(data, isJson) { | |
| let container = document.getElementById('related-results-wrapper'); | |
| if (!container) { | |
| container = document.createElement('div'); | |
| container.id = 'related-results-wrapper'; | |
| document.body.appendChild(container); | |
| } | |
| container.innerHTML = ""; | |
| if (isJson) { | |
| const jsonStr = JSON.stringify(data, null, 2); | |
| container.innerHTML = ` | |
| <div style="background: #000; border: 1px solid #444; border-radius: 8px; overflow: hidden; font-family: monospace;"> | |
| <div style="background: #222; padding: 8px 15px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #444;"> | |
| <span style="color: #00aaff; font-weight: bold; font-size: 12px;">DADOS EM JSON</span> | |
| <button id="btn-copy-json" style="background: #00aaff; color: white; border: none; padding: 5px 12px; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 11px;">COPIAR CÓDIGO</button> | |
| </div> | |
| <pre id="json-content" style="margin: 0; padding: 15px; overflow: auto; font-size: 12px; color: #0f0; white-space: pre-wrap; max-height: 500px;">${escapeHTML(jsonStr)}</pre> | |
| </div>`; | |
| document.getElementById('btn-copy-json').onclick = function() { | |
| const textArea = document.createElement("textarea"); | |
| textArea.value = jsonStr; | |
| document.body.appendChild(textArea); | |
| textArea.select(); | |
| document.execCommand('copy'); | |
| this.innerText = "✅ COPIADO!"; | |
| this.style.background = "#28a745"; | |
| document.body.removeChild(textArea); | |
| setTimeout(() => { | |
| this.innerText = "COPIAR CÓDIGO"; | |
| this.style.background = "#00aaff"; | |
| }, 2000); | |
| }; | |
| } else { | |
| container.innerHTML = data; | |
| } | |
| container.style.display = 'block'; | |
| }; | |
| // --- MODIFICAÇÃO AQUI: Chamar pycmd com as palavras marcadas --- | |
| setTimeout(() => { | |
| wrapWords(document.querySelector('.card') || document.body); | |
| const markedWords = Array.from(document.querySelectorAll('.anki-clickable-word.word-is-linked')) | |
| .map(el => el.getAttribute('data-word')); | |
| pycmd("initial_auto_search:" + JSON.stringify(markedWords)); | |
| }, 650); | |
| })(); | |
| </script> | |
| <style> | |
| .anki-clickable-word { cursor: pointer !important; border-bottom: 1px dashed #666; } | |
| .word-is-linked { color: #ffaa00 !important; font-weight: bold; border-bottom: 2px solid #ffaa00 !important; } | |
| #related-results-wrapper { display: none; margin: 20px 10px; padding: 15px; border-top: 3px solid #00aaff; background: #111; color: #eee; text-align: left; border-radius: 8px; } | |
| .hub-map-container { width: 100%; height: 550px; background: #0a0a0a; border-radius: 15px; position: relative; overflow: hidden; border: 1px solid #333; margin-bottom: 30px; } | |
| .map-svg { width: 100%; height: 100%; display: block; } | |
| .hub-circle { fill: rgba(0, 170, 255, 0.03); stroke: #00aaff; stroke-width: 2; stroke-dasharray: 5; opacity: 0.7; } | |
| .hub-circle.intersection { stroke: #ffaa00; fill: rgba(255, 170, 0, 0.05); } | |
| .hub-label { fill: #00aaff; font-weight: bold; font-size: 11px; text-anchor: middle; pointer-events: none; } | |
| .node-dot { fill: #ffaa00; cursor: pointer; filter: drop-shadow(0 0 5px #ffaa00); transition: r 0.2s; } | |
| .node-dot:hover { r: 8; stroke: white; stroke-width: 2; } | |
| .arrow-line { stroke: #ffaa00; stroke-width: 1.5; fill: none; opacity: 0.4; marker-end: url(#arrowhead); } | |
| .related-item { background: #252525; margin-bottom: 10px; padding: 12px; border-radius: 6px; border: 1px solid #333; position: relative; } | |
| .card-number-badge { background: #444; color: #00aaff; font-size: 10px; padding: 2px 6px; border-radius: 4px; margin-bottom: 8px; display: inline-block; font-weight: bold; border: 1px solid #555; } | |
| .media-btn-mp4 { background: #007bff; color: white; border: none; padding: 8px 15px; border-radius: 5px; cursor: pointer; font-weight: bold; margin: 10px 0; display: block; } | |
| .io-wrapper { position: relative; display: inline-block; margin: 10px 0; background: #000; max-width: 100%; } | |
| .io-img { max-width: 100%; height: auto; display: block; } | |
| .io-svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; } | |
| .io-rect { fill: #ffe066; stroke: #ff922b; stroke-width: 0.005; cursor: pointer; pointer-events: auto; opacity: 1; } | |
| .cloze-toggle { color: #00aaff; font-weight: bold; cursor: pointer; border-bottom: 1px dotted #00aaff; } | |
| .cloze-revealed { color: #ffaa00 !important; border-bottom: none !important; } | |
| </style> | |
| """ | |
| # --- Lógica Python --- | |
| def clean_text_for_search(text): | |
| text = re.sub(r'\[sound:[^\]]+\]', '', text) | |
| text = re.sub(r'<(img|video)[^>]*>', '', text, flags=re.IGNORECASE) | |
| return stripHTML(text).lower() | |
| def process_note_to_html(note, highlight_terms=None, auto_reveal_target=False, reveal_all=False): | |
| all_text = " ".join(note.fields) | |
| io_html = "" | |
| io_src = "" | |
| img_match = re.search(r'<img [^>]*src=["\']([^"\']+)["\'][^>]*>', all_text) | |
| rects = re.findall(r'image-occlusion:rect:left=([\d.]+):top=([\d.]+):width=([\d.]+):height=([\d.]+)', all_text) | |
| if img_match and rects: | |
| io_src = img_match.group(1) | |
| svg_rects = "".join([f'<rect class="io-rect" x="{l}" y="{t}" width="{w}" height="{h}" onclick="event.stopPropagation(); this.style.opacity=(this.style.opacity==\'0\')?\'1\':\'0\';" />' for l, t, w, h in rects]) | |
| io_html = f'<div class="io-wrapper"><img src="{io_src}" class="io-img"><svg class="io-svg" viewBox="0 0 1 1" preserveAspectRatio="none">{svg_rects}</svg></div>' | |
| final_content = [] | |
| if io_html: final_content.append(io_html) | |
| # Prepara os termos de destaque para comparação eficiente | |
| # Garante que highlight_terms seja uma lista, mesmo que None | |
| clean_highlight_terms = [term.lower().replace("*", "") for term in (highlight_terms or [])] | |
| for f_val in note.fields: | |
| text = f_val.strip() | |
| if not text or "image-occlusion:rect" in text or "Oclusão de Imagem" in text: continue | |
| if io_src and io_src in text: | |
| text = re.sub(r'<img [^>]*src=["\']' + re.escape(io_src) + r'["\'][^>]*>', '', text) | |
| if not stripHTML(text).strip(): continue | |
| # --- LÓGICA DE CLOZE CORRIGIDA E INTELIGENTE --- | |
| def replace_cloze(m): | |
| content = m.group(1) # Conteúdo cru do cloze | |
| # Verifica se alguma das palavras buscadas está dentro deste cloze específico | |
| is_target = False | |
| if clean_highlight_terms: | |
| clean_cloze_content = stripHTML(content).lower() | |
| for term in clean_highlight_terms: | |
| # Usar regex para garantir que a palavra seja completa (word boundary) | |
| if re.search(rf'\b{re.escape(term)}\b', clean_cloze_content): | |
| is_target = True | |
| break | |
| # Escapa aspas para não quebrar o JS | |
| safe_content = content.replace("'", "\\'").replace('"', '"') | |
| # Lógica de Revelação: | |
| # 1. Se "Revelar Tudo" estiver ativo -> Mostra | |
| # 2. Se "Revelar Alvo" estiver ativo E for o alvo -> Mostra | |
| # 3. Caso contrário -> Esconde [...] | |
| should_reveal = reveal_all or (auto_reveal_target and is_target) | |
| if should_reveal: | |
| display_text = content | |
| extra_class = "cloze-revealed" | |
| else: | |
| display_text = "[...]" | |
| extra_class = "" | |
| return f'<span class="cloze-toggle {extra_class}" onclick="toggleCloze(this, \'{safe_content}\')">{display_text}</span>' | |
| text = re.sub(r'{{c\d+::(.*?)(::(.*?))?}}', replace_cloze, text) | |
| # ----------------------------------------------- | |
| # Mídias | |
| def replace_media(m): | |
| fname = m.group(1) | |
| quoted = urllib.parse.quote(fname) | |
| ext = fname.split('.')[-1].lower() | |
| if ext == 'mp4': | |
| return f'<button class="media-btn-mp4" onclick="event.stopPropagation(); pycmd(\'open_external:{quoted}\')">📺 ABRIR VÍDEO MP4</button>' | |
| if ext == 'webm': | |
| return f'<div><video controls style="width:100%"><source src="{quoted}"></video></div>' | |
| return f'<div><audio controls src="{quoted}"></audio></div>' | |
| text = re.sub(r'\[sound:(.*?)\]', replace_media, text) | |
| # Highlight visual (amarelo) - CORRIGIDO PARA EVITAR ERRO DE REGEX | |
| if clean_highlight_terms: # Usar os termos limpos para destaque | |
| for term in clean_highlight_terms: | |
| # Regex: Encontra tags HTML (grupo 1) OU o termo (grupo 2) | |
| # Adicionado \b para garantir que seja a palavra completa | |
| pattern = re.compile(rf'(<[^>]+>)|(\b{re.escape(term)}\b)', re.IGNORECASE) | |
| def repl(m): | |
| if m.group(1): | |
| return m.group(1) # É tag, retorna igual | |
| return f'<span style="background:yellow;color:black">{m.group(2)}</span>' # É texto, destaca | |
| text = pattern.sub(repl, text) | |
| final_content.append(f"<div>{text}</div>") | |
| return "".join(final_content) | |
| def get_combined_results_data(marked_words, current_nid): | |
| word_to_cids = {} | |
| all_cids = set() | |
| for word in marked_words: | |
| initial_found = mw.col.find_cards(f'"{word}" -nid:{current_nid}') | |
| filtered = set() | |
| word_lower = word.lower() | |
| for cid in initial_found: | |
| note = mw.col.get_card(cid).note() | |
| clean_content = clean_text_for_search(" ".join(note.fields)) | |
| # Usar regex para garantir que a palavra seja completa (word boundary) | |
| if re.search(rf'\b{re.escape(word_lower)}\b', clean_content): | |
| filtered.add(cid) | |
| word_to_cids[word] = filtered | |
| all_cids.update(filtered) | |
| hubs_data = {(word,): [] for word in marked_words} | |
| for cid in all_cids: | |
| matches = tuple(sorted([w for w in marked_words if cid in word_to_cids[w]])) | |
| if matches not in hubs_data: hubs_data[matches] = [] | |
| hubs_data[matches].append(cid) | |
| return hubs_data, all_cids | |
| def render_results(marked_words, current_nid): | |
| conf = get_config() | |
| limit = conf["result_limit"] | |
| auto_reveal = conf.get("auto_reveal_target", False) # Pega config nova | |
| reveal_all = conf.get("reveal_all_clozes", False) # Pega config nova | |
| hubs_data, all_cids = get_combined_results_data(marked_words, current_nid) | |
| # --- LÓGICA DE FILTRO DE NOTAS ÚNICAS (OBRIGATÓRIA) --- | |
| new_hubs_data = {} | |
| for combo, cids in hubs_data.items(): | |
| seen_nids = set() | |
| unique_cids = [] | |
| for cid in cids: | |
| try: | |
| nid = mw.col.get_card(cid).nid | |
| if nid not in seen_nids: | |
| seen_nids.add(nid) | |
| unique_cids.append(cid) | |
| except: pass | |
| new_hubs_data[combo] = unique_cids | |
| hubs_data = new_hubs_data | |
| all_cids = set() | |
| for cids in hubs_data.values(): | |
| all_cids.update(cids) | |
| # ---------------------------------------------------------- | |
| total_found = len(all_cids) | |
| total_shown = min(total_found, limit) | |
| if conf.get("output_format") == "json": | |
| json_data = [] | |
| for combo, cids in hubs_data.items(): | |
| notes = [{"fields": mw.col.get_card(c).note().fields} for c in cids[:limit]] | |
| json_data.append({"term": " + ".join(combo), "total": len(cids), "notes": notes}) | |
| return json_data, True | |
| map_html = "" | |
| if conf.get("show_map", True): | |
| width, height = 800, 600 | |
| cx, cy = width / 2, height / 2 | |
| hub_radius = 75 | |
| combos_list = list(hubs_data.keys()) | |
| layout_radius = 150 | |
| hub_positions = {} | |
| svg_elements = ['<defs><marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#ffaa00" /></marker></defs>'] | |
| for i, combo in enumerate(combos_list): | |
| angle = (2 * math.pi * i) / len(combos_list) | |
| hx, hy = cx + layout_radius * math.cos(angle), cy + layout_radius * math.sin(angle) | |
| hub_positions[combo] = (hx, hy) | |
| for combo, cids in hubs_data.items(): | |
| hx, hy = hub_positions[combo] | |
| is_inter = len(combo) > 1 | |
| svg_elements.append(f'<circle cx="{hx}" cy="{hy}" r="{hub_radius}" class="hub-circle {"intersection" if is_inter else ""}" />') | |
| svg_elements.append(f'<text x="{hx}" y="{hy + hub_radius + 18}" class="hub-label">{ " + ".join(combo) } ({len(cids)})</text>') | |
| if is_inter: | |
| for word in combo: | |
| tx, ty = hub_positions[(word,)] | |
| svg_elements.append(f'<line x1="{hx}" y1="{hy}" x2="{tx}" y2="{ty}" class="arrow-line" />') | |
| for cid in cids[:limit]: | |
| r, a = random.uniform(0, hub_radius - 15), random.uniform(0, 2 * math.pi) | |
| svg_elements.append(f'<circle cx="{hx + r * math.cos(a)}" cy="{hy + r * math.sin(a)}" r="5" class="node-dot" onclick="pycmd(\'edit_note_by_cid:{cid}\')" />') | |
| map_html = f'<div class="hub-map-container"><svg class="map-svg" viewBox="0 0 800 600">{"".join(svg_elements)}</svg></div>' | |
| header_text = f"🔍 Resultados Encontrados: {total_found} (Exibindo máx {limit} por grupo)" | |
| list_html = [f"<div style='color:#00aaff; font-weight:bold; margin-bottom:15px; border-bottom:1px solid #333; padding-bottom:5px;'>{header_text}</div>"] | |
| for combo, cids in hubs_data.items(): | |
| group_total = len(cids) | |
| group_shown = min(group_total, limit) | |
| list_html.append(f"<div><h3 style='color:#ffaa00; margin-bottom:10px;'>{' + '.join(combo)} ({group_shown} de {group_total})</h3>") | |
| items = [] | |
| for idx, cid in enumerate(cids[:limit], 1): | |
| note = mw.col.get_card(cid).note() | |
| # Passa os parâmetros de revelação | |
| # IMPORTANTE: Passar 'list(combo)' para highlight_terms para que todos os termos do grupo sejam considerados | |
| content = process_note_to_html(note, list(combo), auto_reveal, reveal_all) | |
| items.append(f"<div class='related-item' onclick='pycmd(\"edit_note:{note.id}\")'><div class='card-number-badge'>NOTA #{idx}</div>{content}</div>") | |
| list_html.append("".join(items) + "</div>") | |
| return map_html + "".join(list_html), False | |
| def on_js_message(handled, message, context): | |
| card = getattr(mw.reviewer, "card", None) | |
| if not card: return handled | |
| if message.startswith("open_external:"): | |
| fname = urllib.parse.unquote(message.split(":")[1]) | |
| path = os.path.join(mw.col.media.dir(), fname) | |
| from aqt.qt import QDesktopServices, QUrl | |
| QDesktopServices.openUrl(QUrl.fromLocalFile(path)) | |
| return (True, None) | |
| if message.startswith("edit_note:"): | |
| nid = int(message.split(":")[1]); browser = aqt.dialogs.open("Browser", mw); browser.search_for_terms(f"nid:{nid}") | |
| return (True, None) | |
| if message.startswith("edit_note_by_cid:"): | |
| cid = int(message.split(":")[1]); nid = mw.col.get_card(cid).nid; browser = aqt.dialogs.open("Browser", mw); browser.search_for_terms(f"nid:{nid}") | |
| return (True, None) | |
| if message.startswith("initial_auto_search:"): | |
| marked_words = json.loads(message.replace("initial_auto_search:", "")) | |
| if marked_words: | |
| data, is_json = render_results(marked_words, card.note().id) | |
| mw.reviewer.web.eval(f"window.onAnkiSearchResults({json.dumps(data)}, {json.dumps(is_json)});") | |
| else: | |
| mw.reviewer.web.eval("document.getElementById('related-results-wrapper').style.display='none';") | |
| return (True, None) | |
| if message.startswith("word_search_action:"): | |
| data_json = json.loads(message.replace("word_search_action:", "")) | |
| toggle_word = data_json.get("toggle_word") | |
| will_be_linked = data_json.get("will_be_linked") # Novo parâmetro do JS | |
| marked_words = data_json.get("all_marked", []) # Usar a lista atualizada do JS | |
| conf = get_config() | |
| if toggle_word and conf["mode"] == "asterisk": | |
| note = card.note() | |
| for i, f in enumerate(note.fields): | |
| # Usar regex para substituir apenas a palavra completa | |
| # e evitar substituir partes de outras palavras. | |
| # Ex: "hora" não deve afetar "horario" | |
| # Se will_be_linked é True, significa que a palavra DEVE ser marcada com asterisco | |
| if will_be_linked: | |
| # Apenas adiciona se ainda não tiver asterisco | |
| if re.search(rf'\b{re.escape(toggle_word)}\b', f) and not re.search(rf'\b{re.escape(toggle_word)}\*\b', f): | |
| note.fields[i] = re.sub(rf'\b{re.escape(toggle_word)}\b', toggle_word + '*', f) | |
| # Se will_be_linked é False, significa que a palavra DEVE ser desmarcada | |
| else: | |
| # Apenas remove se tiver asterisco | |
| if re.search(rf'\b{re.escape(toggle_word)}\*\b', f): | |
| note.fields[i] = re.sub(rf'\b{re.escape(toggle_word)}\*\b', toggle_word, f) | |
| mw.col.update_note(note) | |
| # Sempre renderizar os resultados com base nas palavras marcadas ATUAIS | |
| if not marked_words: # Se a lista de marcadas estiver vazia, esconder | |
| mw.reviewer.web.eval("document.getElementById('related-results-wrapper').style.display='none';") | |
| else: | |
| data, is_json = render_results(marked_words, card.note().id) | |
| mw.reviewer.web.eval(f"window.onAnkiSearchResults({json.dumps(data)}, {json.dumps(is_json)});") | |
| return (True, None) | |
| return handled | |
| card_will_show.append(lambda html, card, kind: html + JS_HANDLER) | |
| webview_did_receive_js_message.append(on_js_message) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment