Created
May 9, 2025 17:22
-
-
Save eros18123/26ba25b35bf5304ddc6a0bba3596d9ed to your computer and use it in GitHub Desktop.
Multiple apkg export and import 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
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