Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save eros18123/26ba25b35bf5304ddc6a0bba3596d9ed to your computer and use it in GitHub Desktop.
Save eros18123/26ba25b35bf5304ddc6a0bba3596d9ed to your computer and use it in GitHub Desktop.
Multiple apkg export and import 1.2
import os
import time
from aqt import mw, gui_hooks
from aqt.qt import Qt, QAction, QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QProgressDialog
from aqt.qt import QCheckBox, QScrollArea, QWidget, QLineEdit, QFileDialog, QMessageBox, QTabWidget, QListWidget
from aqt.qt import QListWidgetItem, QAbstractItemView, QThread, pyqtSignal, QTimer
from aqt.utils import showInfo
from anki.exporting import AnkiPackageExporter
from anki.importing import AnkiPackageImporter
from anki.collection import Collection
from anki.notes import Note
import glob
def get_ankidesk_path():
desktop = os.path.expanduser("~/Desktop")
ankidesk_path = os.path.join(desktop, "ankidesk")
if not os.path.exists(ankidesk_path):
os.makedirs(ankidesk_path)
return ankidesk_path
def get_subdecks_and_notes(col, did, deck_name):
current_deck_object = col.decks.get(did)
if not current_deck_object or current_deck_object['name'] != deck_name:
found_deck_info = col.decks.by_name(deck_name)
if not found_deck_info:
raise Exception(f"Deck {deck_name} não encontrado na coleção ao verificar ID.")
did = found_deck_info['id']
deck_ids_to_process = col.decks.deck_and_child_ids(did)
if not deck_ids_to_process:
raise Exception(f"Não foi possível obter a hierarquia de decks para {deck_name} (ID {did}).")
note_ids = col.db.list(
"SELECT DISTINCT notes.id FROM cards "
"JOIN notes ON cards.nid = notes.id "
"WHERE cards.did IN ({})".format(','.join(str(d_id) for d_id in deck_ids_to_process))
)
temp_notes_added_this_deck = []
if not note_ids:
deck_name_pattern = deck_name + "%" # Busca notas em subdecks se o primeiro método falhar
note_ids = col.db.list(
"SELECT DISTINCT notes.id FROM cards "
"JOIN notes ON cards.nid = notes.id "
"JOIN decks ON cards.did = decks.id "
"WHERE decks.name LIKE ?",
deck_name_pattern
)
# Adicionar nota placeholder se nenhuma nota for encontrada no deck principal
if not note_ids:
model = col.models.byName("Basic") or col.models.byName("Básico")
if not model:
model = col.models.new("Basic")
front_field = col.models.newField("Front")
back_field = col.models.newField("Back")
col.models.addField(model, front_field)
col.models.addField(model, back_field)
template = col.models.newTemplate("Card 1")
template['qfmt'] = '{{Front}}'
template['afmt'] = '{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}'
col.models.addTemplate(model, template)
col.models.save(model)
note = Note(col, model)
fields = col.models.fieldNames(model)
if "Front" in fields: note["Front"] = "Placeholder"
if "Back" in fields: note["Back"] = "Temporary note for export"
col.addNote(note)
# Garantir que a nota seja adicionada ao deck correto (o deck pai da hierarquia)
col.set_deck(note.card_ids(), did)
note_ids = [note.id]
temp_notes_added_this_deck.append(note.id)
# Verificar cada subdeck na hierarquia e adicionar nota placeholder se estiver vazio
for deck_id_in_hierarchy in deck_ids_to_process:
card_count = col.db.scalar("SELECT COUNT() FROM cards WHERE did = ?", deck_id_in_hierarchy)
if card_count == 0:
model = col.models.byName("Basic") or col.models.byName("Básico")
if not model: # Cria modelo básico se não existir
model = col.models.new("Basic")
front_field = col.models.newField("Front")
back_field = col.models.newField("Back")
col.models.addField(model, front_field)
col.models.addField(model, back_field)
template = col.models.newTemplate("Card 1")
template['qfmt'] = '{{Front}}'
template['afmt'] = '{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}'
col.models.addTemplate(model, template)
col.models.save(model)
note = Note(col, model)
fields = col.models.fieldNames(model)
if "Front" in fields: note["Front"] = "Placeholder"
if "Back" in fields: note["Back"] = "Temporary note for export"
col.addNote(note)
# Adiciona a nota ao subdeck específico que está vazio
col.set_deck(note.card_ids(), deck_id_in_hierarchy)
if note.id not in note_ids: # Adiciona à lista de exportação se não estiver já
note_ids.append(note.id)
temp_notes_added_this_deck.append(note.id)
# Retorna o ID do deck pai para a exportação, a lista de notas e as notas temporárias adicionadas
return did, list(set(note_ids)), temp_notes_added_this_deck
class ExportThread(QThread):
progress = pyqtSignal(int)
finished = pyqtSignal(str, bool, str)
def __init__(self, col, did_for_export, deck_name, file_path, note_ids_to_export):
super().__init__()
self.col = col
self.did_for_export = did_for_export
self.deck_name = deck_name
self.file_path = file_path
self.note_ids_to_export = note_ids_to_export
self._last_progress_emitted = -1
def _emit_progress(self, value):
# Garante que o progresso só seja emitido se for maior que o último valor emitido
# e não seja maior que 100.
if value > self._last_progress_emitted and value <= 100:
self.progress.emit(value)
self._last_progress_emitted = value
def run(self):
try:
self._emit_progress(0)
exporter = AnkiPackageExporter(self.col)
exporter.did = self.did_for_export # Usar o ID do deck pai para a exportação
exporter.includeMedia = True
exporter.includeSched = True # Incluir agendamento
exporter.includeTags = True # Incluir tags
exporter.noteIds = self.note_ids_to_export # Exportar apenas as notas relevantes
self._emit_progress(5) # Pequeno progresso antes de iniciar a exportação principal
self._emit_progress(10) # Progresso indicando que a exportação vai começar
exporter.exportInto(self.file_path) # Processo principal de exportação
self._emit_progress(90) # Progresso indicando que a exportação terminou
if not os.path.exists(self.file_path):
raise Exception("Arquivo .apkg não foi criado.")
self._emit_progress(100) # Exportação concluída
self.finished.emit(self.file_path, True, "")
except Exception as e:
self._emit_progress(100) # Mesmo em caso de erro, emitir 100 para fechar a barra
self.finished.emit(self.file_path, False, str(e))
class ExportImportDialog(QDialog):
def __init__(self):
super().__init__(None, Qt.WindowType.Window | Qt.WindowType.WindowMinimizeButtonHint |
Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowStaysOnTopHint)
self.setWindowTitle("Exportar/Importar APKG")
self.setModal(False)
self.setGeometry(100, 100, 600, 500)
layout = QVBoxLayout()
self.tabs = QTabWidget()
export_tab = QWidget()
export_layout = QVBoxLayout(export_tab)
search_layout = QHBoxLayout()
search_label = QLabel("Pesquisar deck:")
self.search_field = QLineEdit()
self.search_field.setPlaceholderText("Digite para filtrar os decks...")
self.search_field.textChanged.connect(self.filter_decks)
search_layout.addWidget(search_label)
search_layout.addWidget(self.search_field)
export_layout.addLayout(search_layout)
self.total_decks_label = QLabel("Carregando decks...")
self.selected_decks_label = QLabel("Decks selecionados: 0")
export_layout.addWidget(self.total_decks_label)
export_layout.addWidget(self.selected_decks_label)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
self.scroll_content = QWidget()
self.scroll_layout = QVBoxLayout(self.scroll_content)
scroll_area.setWidget(self.scroll_content)
export_layout.addWidget(scroll_area)
export_button_layout = QHBoxLayout()
self.toggle_all_button = QPushButton("Selecionar Todos")
self.toggle_all_button.clicked.connect(self.toggle_all_decks)
export_button_layout.addWidget(self.toggle_all_button)
export_button = QPushButton("Exportar Selecionados")
export_button.clicked.connect(self.start_export)
export_button_layout.addWidget(export_button)
export_layout.addLayout(export_button_layout)
import_tab = QWidget()
import_layout = QVBoxLayout(import_tab)
self.folder_label = QLabel("Nenhuma pasta selecionada")
import_layout.addWidget(self.folder_label)
import_buttons_layout = QHBoxLayout()
select_folder_button = QPushButton("Selecionar Pasta")
select_folder_button.clicked.connect(self.select_folder)
import_buttons_layout.addWidget(select_folder_button)
add_files_button = QPushButton("Adicionar Arquivos")
add_files_button.clicked.connect(self.add_files)
import_buttons_layout.addWidget(add_files_button)
import_layout.addLayout(import_buttons_layout)
self.files_list = QListWidget()
self.files_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
import_layout.addWidget(self.files_list)
self.files_count_label = QLabel("Arquivos para importar: 0")
import_layout.addWidget(self.files_count_label)
import_button_layout = QHBoxLayout()
import_selected_button = QPushButton("Importar Selecionados")
import_selected_button.clicked.connect(lambda: self.start_import(selected_only=True))
import_button_layout.addWidget(import_selected_button)
import_all_button = QPushButton("Importar Todos")
import_all_button.clicked.connect(lambda: self.start_import(selected_only=False))
import_button_layout.addWidget(import_all_button)
import_layout.addLayout(import_button_layout)
self.tabs.addTab(export_tab, "Exportar")
self.tabs.addTab(import_tab, "Importar")
layout.addWidget(self.tabs)
self.setLayout(layout)
self.folder_path = ""
self.deck_checkboxes = []
self.current_deck_progress_dialog = None
self.simulation_timer = QTimer(self)
self.simulation_timer.timeout.connect(self._advance_simulated_progress)
self.simulation_current_value = 0
self.simulation_target_value = 89
self.populate_deck_list()
def populate_deck_list(self):
# Limpa os checkboxes antigos
for checkbox in self.deck_checkboxes:
self.scroll_layout.removeWidget(checkbox)
checkbox.deleteLater()
self.deck_checkboxes = []
# Limpa qualquer widget restante (como o QSpacerItem)
for i in reversed(range(self.scroll_layout.count())):
item = self.scroll_layout.itemAt(i)
if item.widget():
item.widget().deleteLater()
else: # Spacer item
self.scroll_layout.removeItem(item)
all_decks = list(mw.col.decks.all_names_and_ids())
self.total_decks_label.setText(f"Total de decks: {len(all_decks)}")
# Ordena os decks alfabeticamente para melhor visualização
for deck_name_id in sorted(all_decks, key=lambda x: x.name.lower()):
checkbox = QCheckBox(deck_name_id.name)
checkbox.setChecked(False)
checkbox.deck_id = deck_name_id.id
checkbox.stateChanged.connect(self.update_selected_count)
self.deck_checkboxes.append(checkbox)
self.scroll_layout.addWidget(checkbox)
self.scroll_layout.addStretch() # Adiciona um espaçador no final
self.update_selected_count()
def update_selected_count(self):
selected_count = sum(1 for cb in self.deck_checkboxes if cb.isChecked() and not cb.isHidden())
self.selected_decks_label.setText(f"Decks selecionados: {selected_count}")
visible_checkboxes = [cb for cb in self.deck_checkboxes if not cb.isHidden()]
all_checked = all(cb.isChecked() for cb in visible_checkboxes) if visible_checkboxes else False
self.toggle_all_button.setText("Deselecionar Todos" if all_checked else "Selecionar Todos")
def toggle_all_decks(self):
visible_checkboxes = [cb for cb in self.deck_checkboxes if not cb.isHidden()]
if not visible_checkboxes: return
# Verifica se todos os visíveis estão selecionados para determinar a ação
all_currently_checked = all(cb.isChecked() for cb in visible_checkboxes)
for checkbox in visible_checkboxes:
checkbox.setChecked(not all_currently_checked)
self.update_selected_count()
def filter_decks(self):
search_text = self.search_field.text().lower()
for checkbox in self.deck_checkboxes:
# Torna o checkbox visível se o texto de busca estiver no nome do deck ou se a busca estiver vazia
checkbox.setVisible(not search_text or search_text in checkbox.text().lower())
self.update_selected_count() # Atualiza a contagem e o botão "Selecionar Todos"
def select_folder(self):
folder_path = QFileDialog.getExistingDirectory(self, "Selecionar Pasta com Arquivos APKG")
if folder_path:
self.folder_path = folder_path
self.folder_label.setText(f"Pasta: {folder_path}")
self.update_files_from_folder()
def update_files_from_folder(self):
if self.folder_path:
new_files = glob.glob(os.path.join(self.folder_path, "*.apkg"))
# Cria um conjunto de caminhos existentes para evitar duplicatas
existing_paths = {self.files_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.files_list.count())}
for file_path in new_files:
if file_path not in existing_paths:
item = QListWidgetItem(os.path.basename(file_path))
item.setData(Qt.ItemDataRole.UserRole, file_path) # Armazena o caminho completo
self.files_list.addItem(item)
self.update_files_count()
def add_files(self):
files, _ = QFileDialog.getOpenFileNames(self, "Selecionar Arquivos APKG", "", "Anki Packages (*.apkg);;All Files (*)")
if files:
existing_paths = {self.files_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.files_list.count())}
for file_path in files:
if file_path not in existing_paths:
item = QListWidgetItem(os.path.basename(file_path))
item.setData(Qt.ItemDataRole.UserRole, file_path)
self.files_list.addItem(item)
self.update_files_count()
def clear_files(self):
self.files_list.clear()
self.update_files_count()
def update_export_tab(self):
self.populate_deck_list() # Recarrega a lista de decks na aba de exportação
def update_files_count(self):
self.files_count_label.setText(f"Arquivos para importar: {self.files_list.count()}")
def start_import(self, selected_only=False):
files_to_import = []
if selected_only:
selected_items = self.files_list.selectedItems()
if not selected_items:
showInfo("Nenhum arquivo selecionado para importação.")
return
files_to_import = [item.data(Qt.ItemDataRole.UserRole) for item in selected_items]
else:
if self.files_list.count() == 0:
showInfo("Nenhum arquivo na lista para importação.")
return
files_to_import = [self.files_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.files_list.count())]
if not files_to_import: return
progress = QProgressDialog("Importando decks...", "Cancelar", 0, len(files_to_import), self)
progress.setWindowTitle("Importação em Andamento")
progress.setWindowModality(Qt.WindowModality.WindowModal)
progress.setAutoClose(True); progress.setAutoReset(True) # AutoReset para limpar após fechar
progress.setMinimumDuration(0)
errors, successful = [], 0
for i, file_path in enumerate(files_to_import):
if progress.wasCanceled(): break
file_name = os.path.basename(file_path)
progress.setValue(i)
progress.setLabelText(f"Importando {file_name} ({i + 1}/{len(files_to_import)})...")
mw.app.processEvents() # Permite que a GUI seja atualizada
try:
AnkiPackageImporter(mw.col, file_path).run()
successful += 1
except Exception as e: errors.append(f"Erro ao importar {file_name}: {e}")
progress.setValue(len(files_to_import)) # Garante que a barra vá até o final
if errors:
QMessageBox.warning(self, "Erros na Importação", f"Sucesso: {successful}/{len(files_to_import)}\nErros:\n{os.linesep.join(errors)}")
mw.reset() # Recarrega a coleção e a interface do Anki
# Remove os itens importados da lista
items_to_remove_from_list = []
if selected_only:
items_to_remove_from_list = selected_items
else: # Se importou todos, pega todos os itens
items_to_remove_from_list = [self.files_list.item(i) for i in range(self.files_list.count())]
for item_to_remove in reversed(items_to_remove_from_list): # Remove de trás para frente
self.files_list.takeItem(self.files_list.row(item_to_remove))
self.update_files_count()
self.update_export_tab() # Atualiza a lista de decks na aba de exportação
def _on_deck_export_progress(self, value):
if not self.current_deck_progress_dialog: return
if value == 10 and not self.simulation_timer.isActive():
self.current_deck_progress_dialog.setValue(value)
self.simulation_current_value = value
self.simulation_timer.start(150)
elif value >= 90:
if self.simulation_timer.isActive():
self.simulation_timer.stop()
self.current_deck_progress_dialog.setValue(value)
elif not self.simulation_timer.isActive():
self.current_deck_progress_dialog.setValue(value)
mw.app.processEvents()
def _advance_simulated_progress(self):
if not self.current_deck_progress_dialog or not self.simulation_timer.isActive():
return
if self.simulation_current_value < self.simulation_target_value:
self.simulation_current_value += 1
self.current_deck_progress_dialog.setValue(self.simulation_current_value)
else:
self.simulation_timer.stop()
def start_export(self):
selected_decks_info = [(cb.deck_id, cb.text()) for cb in self.deck_checkboxes if cb.isChecked() and not cb.isHidden()]
if not selected_decks_info:
showInfo("Nenhum deck selecionado para exportar.")
return
main_progress = QProgressDialog("Exportando decks...", "Cancelar", 0, len(selected_decks_info), self)
main_progress.setWindowTitle("Exportação Geral")
main_progress.setWindowModality(Qt.WindowModality.WindowModal)
main_progress.setAutoClose(False); main_progress.setAutoReset(False)
main_progress.setMinimumDuration(0)
ankidesk_path = get_ankidesk_path()
errors = []
all_temp_notes_added_ids = []
export_was_manually_canceled_by_user = False
for i, (did, deck_name) in enumerate(selected_decks_info):
main_progress.setValue(i)
main_progress.setLabelText(f"Processando deck {deck_name} ({i + 1}/{len(selected_decks_info)})...")
mw.app.processEvents()
if main_progress.wasCanceled():
export_was_manually_canceled_by_user = True
break # Sai do loop principal se a exportação geral for cancelada
self.current_deck_progress_dialog = QProgressDialog(f"Exportando {deck_name}...", "Cancelar", 0, 100, self)
self.current_deck_progress_dialog.setWindowTitle(f"Progresso: {deck_name}")
self.current_deck_progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
self.current_deck_progress_dialog.setAutoClose(False); self.current_deck_progress_dialog.setAutoReset(False)
self.current_deck_progress_dialog.setMinimumDuration(0)
export_thread = None
try:
safe_deck_name = "".join(c for c in deck_name if c.isalnum() or c in (' ', '_', '-')).strip()
if not safe_deck_name: safe_deck_name = f"deck_{did}"
file_path = os.path.join(ankidesk_path, f"{safe_deck_name}.apkg")
did_for_export, note_ids_to_export, temp_notes_for_this_deck = get_subdecks_and_notes(mw.col, did, deck_name)
all_temp_notes_added_ids.extend(temp_notes_for_this_deck)
export_thread = ExportThread(mw.col, did_for_export, deck_name, file_path, note_ids_to_export)
export_completed_flag = [False]
export_error_msg = [""]
def on_export_finished_slot(finished_file_path, success, error_str):
export_completed_flag[0] = True
if not success: export_error_msg[0] = error_str
if self.simulation_timer.isActive(): self.simulation_timer.stop()
export_thread.progress.connect(self._on_deck_export_progress)
export_thread.finished.connect(on_export_finished_slot)
export_thread.start()
while not export_completed_flag[0]:
if self.current_deck_progress_dialog.wasCanceled():
if export_thread and export_thread.isRunning():
export_thread.terminate()
export_thread.wait(1000) # Espera um pouco pela thread
# Considera cancelamento do deck individual como cancelamento geral para simplificar
export_was_manually_canceled_by_user = True
raise Exception("Exportação do deck cancelada pelo usuário.")
mw.app.processEvents()
QThread.msleep(50)
if export_error_msg[0]:
raise Exception(export_error_msg[0])
if self.current_deck_progress_dialog: # Verifica se ainda existe
self.current_deck_progress_dialog.setValue(100)
except Exception as e:
errors.append(f"Erro ao exportar {deck_name}: {e}")
if export_was_manually_canceled_by_user: # Se foi cancelado, sai do loop de decks
break
finally:
if export_thread and export_thread.isRunning():
export_thread.terminate()
export_thread.wait(1000)
if self.simulation_timer.isActive():
self.simulation_timer.stop()
if self.current_deck_progress_dialog:
self.current_deck_progress_dialog.close()
self.current_deck_progress_dialog.deleteLater()
self.current_deck_progress_dialog = None
main_progress.setValue(len(selected_decks_info)) # Garante que a barra principal chegue ao fim
main_progress.close() # Fecha a barra de progresso principal
main_progress.deleteLater()
unique_temp_notes = len(set(all_temp_notes_added_ids))
if unique_temp_notes > 0 and not export_was_manually_canceled_by_user :
QMessageBox.information(self, "Notas Temporárias",
f"{unique_temp_notes} nota(s) temporária(s) foram adicionadas para garantir a estrutura de decks vazios. "
"Procure por 'Placeholder' para removê-las se desejar.")
if export_was_manually_canceled_by_user:
showInfo("Exportação cancelada pelo usuário.")
elif errors:
QMessageBox.warning(self, "Exportação Concluída com Erros", f"Alguns decks não puderam ser exportados:\n{os.linesep.join(errors)}")
# else: # Nenhuma mensagem se tudo ocorreu bem e não foi cancelado
# showInfo(f"Decks exportados com sucesso para {ankidesk_path}") # Removido
mw.col.save()
mw.reset()
dialog = None
def open_export_import_dialog():
global dialog
if dialog is None or not dialog.isVisible():
dialog = ExportImportDialog()
dialog.show()
dialog.activateWindow()
dialog.raise_()
def add_menu_action():
action = QAction("Exportar/Importar APKG (Avançado)", mw)
action.triggered.connect(open_export_import_dialog)
if mw.form.menuTools: # Garante que o menu exista
mw.form.menuTools.addAction(action)
# Adiciona o hook para executar após a GUI estar pronta, se necessário
# gui_hooks.main_window_did_init.append(add_menu_action)
# Ou adiciona diretamente se o contexto permitir (como em um __init__.py de addon)
if mw:
add_menu_action()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment