From de927b1c47a5bd506bdd66dc865545a7cb677457 Mon Sep 17 00:00:00 2001 From: Alberto Trigo Date: Thu, 8 May 2025 22:13:46 +0200 Subject: [PATCH 01/10] Create main.py --- japanese/known_words/main.py | 1129 ++++++++++++++++++++++++++++++++++ 1 file changed, 1129 insertions(+) create mode 100644 japanese/known_words/main.py diff --git a/japanese/known_words/main.py b/japanese/known_words/main.py new file mode 100644 index 00000000..e824b81f --- /dev/null +++ b/japanese/known_words/main.py @@ -0,0 +1,1129 @@ +import re +import csv +import os +import datetime +import logging + +from typing import Set, List, Dict, Optional, Any, Tuple + +from aqt import mw +from aqt.qt import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, Qt, + QComboBox, QSpinBox, QFormLayout, QFileDialog, QLineEdit, + QRadioButton, QButtonGroup, QCheckBox, QTextEdit, QGroupBox, + QApplication, QWidget, QTabWidget, + QFrame, +) +from aqt.utils import showInfo, QProgressDialog, tooltip +from anki.utils import strip_html_media + + +# --- Configuration Keys --- +CONFIG_KEY_LAST_CSV_PATH = "knownWordsCsvLastPath" +CONFIG_KEY_NOTE_TYPE_FILTER_STRING = "knownWordsCsvNoteTypeFilterString" +CONFIG_KEY_LAST_FIELD = "knownWordsCsvLastField" +CONFIG_KEY_LAST_INTERVAL = "knownWordsCsvLastInterval" +CONFIG_KEY_LAST_OPERATION_MODE = "knownWordsCsvLastOpMode" +CONFIG_KEY_LEMMATIZE = "knownWordsCsvLemmatize" +CONFIG_KEY_FILTER_BY_DICT = "knownWordsCsvFilterByDict" +CONFIG_KEY_LAST_DICT_PATH = "knownWordsCsvLastDictPath" +CONFIG_KEY_AUTO_TIMESTAMP_FILENAME = "knownWordsCsvAutoTimestamp" +CONFIG_KEY_CUSTOM_STOPWORDS = "knownWordsCsvCustomStopwords" +CONFIG_KEY_CUSTOM_STOPWORDS_MODE = "knownWordsCsvCustomStopwordsMode" + +# --- String Constants --- +STOPWORDS_MODE_SUPPLEMENT = "supplement" +STOPWORDS_MODE_REPLACE = "replace" + +CSV_COLUMN_WORD = "Word" +CSV_COLUMN_SOURCE = "Source" + +ANKI_SOURCE_TAG = "anki" + +OP_MODE_UPDATE_EXISTING = "update_existing" +OP_MODE_SAVE_AS_NEW = "save_as_new" + +DEFAULT_SAVE_FILENAME = "known_words.csv" +DEFAULT_NOTE_TYPE_FILTER_STRING = "japanese" +DEFAULT_MATURE_INTERVAL = 21 + +_SINGLE_DBL_QUOTE = '"' +_ESCAPED_DBL_QUOTE = '""' + + +# --- Logging Setup --- +log = logging.getLogger(__name__) + + +# --- MeCab Dependency Handling --- +try: + from ..mecab_controller import MecabController + from ..mecab_controller.basic_types import PartOfSpeech + MECAB_AVAILABLE = True +except ImportError: + MECAB_AVAILABLE = False + log.warning("MecabController or basic_types not found. Lemmatization will be disabled.") + + class MecabController: # type: ignore + def translate(self, text: str) -> list: + return [] + + class _DummyPosValue: + def __init__(self, value: str): + self.value = value + def __str__(self) -> str: + return self.value + def __repr__(self) -> str: + return f"_DummyPosValue('{self.value}')" + + class DummyPartOfSpeech: # type: ignore + particle = _DummyPosValue("DUMMY_POS_particle") + bound_auxiliary = _DummyPosValue("DUMMY_POS_bound_auxiliary") + symbol = _DummyPosValue("DUMMY_POS_symbol") + filler = _DummyPosValue("DUMMY_POS_filler") + interjection = _DummyPosValue("DUMMY_POS_interjection") + prefix = _DummyPosValue("DUMMY_POS_prefix") + verb = _DummyPosValue("DUMMY_POS_verb") + noun = _DummyPosValue("DUMMY_POS_noun") + i_adjective = _DummyPosValue("DUMMY_POS_i_adjective") + adverb = _DummyPosValue("DUMMY_POS_adverb") + conjunction = _DummyPosValue("DUMMY_POS_conjunction") + adnominal_adjective = _DummyPosValue("DUMMY_POS_adnominal_adjective") + unknown = _DummyPosValue("DUMMY_POS_unknown") + other = _DummyPosValue("DUMMY_POS_other") + + def __getattr__(self, name: str) -> _DummyPosValue: + log.debug(f"DummyPartOfSpeech.__getattr__ called for unknown POS: {name}") + return _DummyPosValue(f"DUMMY_POS_unknown_{name.lower()}") + + PartOfSpeech = DummyPartOfSpeech() + +def _load_dictionary_file(filepath: str) -> Set[str]: + dictionary_set: Set[str] = set() + if not filepath or not os.path.exists(filepath): + return dictionary_set + + try: + with open(filepath, 'r', encoding='utf-8') as f: + for line in f: + word = line.strip() + if word: + dictionary_set.add(word) + except (OSError, UnicodeDecodeError) as e: # MODIFIED: IOError to OSError + log.error(f"Error reading dictionary file '{os.path.basename(filepath)}': {e}", exc_info=True) + showInfo(f"Error reading dictionary file '{os.path.basename(filepath)}':\n{e}") + except Exception as e: + log.error(f"Unexpected error reading dictionary file '{os.path.basename(filepath)}': {e}", exc_info=True) + showInfo(f"An unexpected error occurred while reading dictionary file '{os.path.basename(filepath)}':\n{e}") + return dictionary_set + + +class MeCabProcessor: + STATUS_UNINITIALIZED = "UNINITIALIZED" + STATUS_INITIALIZED = "INITIALIZED" + STATUS_FAILED = "FAILED" + + DEFAULT_POS_TO_SKIP_NAMES: List[str] = [ + "particle", "bound_auxiliary", "symbol", "filler", + "interjection", "prefix", + ] + SINGLE_KANA_REGEX = re.compile(r'^[\u3040-\u309F\u30A0-\u30FF]$') + FULLWIDTH_ALPHANUM_REGEX = re.compile(r'^[A-Za-z0-9]+$') + COUNTER_REGEX = re.compile(r'^\d+[つ個台本枚匹頭羽人日円年々ヵヶ箇カ]$') + UNWANTED_SYMBOLS: Set[str] = {"「", "」", "、", "。", "?", "!", ":", ";", "(", ")", "【", "】", "『", "』"} + + def __init__(self, custom_stopwords_str: str = "", stopwords_mode: str = STOPWORDS_MODE_SUPPLEMENT): + self.mecab_controller: Optional[MecabController] = None + self._pos_to_skip: Set[str] = set() + self.pos_init_status: str = self.STATUS_UNINITIALIZED + self.combined_lemma_stop_list: Set[str] = set() + + self._configure_stopwords(custom_stopwords_str, stopwords_mode) + + if MECAB_AVAILABLE: + try: + self.mecab_controller = MecabController() + self._initialize_pos_skip_set() + except Exception as e: + log.error(f"Failed to initialize MeCabController: {e}", exc_info=True) + self.pos_init_status = self.STATUS_FAILED + self.mecab_controller = None + else: + log.info("MeCab is not available. Lemmatization features will be disabled.") + self.pos_init_status = self.STATUS_FAILED + + def _configure_stopwords(self, custom_stopwords_str: str, stopwords_mode: str) -> None: + base_stopwords = {"ある", "いる", "する", "なる", "思う"} + custom_stopwords = {word.strip() for word in custom_stopwords_str.splitlines() if word.strip()} + + if stopwords_mode == STOPWORDS_MODE_REPLACE: + self.combined_lemma_stop_list = custom_stopwords + else: + self.combined_lemma_stop_list = base_stopwords.union(custom_stopwords) + + def _initialize_pos_skip_set(self) -> None: + if self.pos_init_status != self.STATUS_UNINITIALIZED: + return + + temp_pos_set: Set[str] = set() + try: + for pos_name in self.DEFAULT_POS_TO_SKIP_NAMES: + if hasattr(PartOfSpeech, pos_name): + pos_attr = getattr(PartOfSpeech, pos_name) + if hasattr(pos_attr, 'value'): + temp_pos_set.add(pos_attr.value) + else: + log.warning(f"PartOfSpeech attribute '{pos_name}' (type: {type(pos_attr)}) lacks a '.value' attribute. Skipping.") + else: + log.warning(f"PartOfSpeech enum (real or dummy) does not have attribute '{pos_name}'. Skipping for POS filter.") + + self._pos_to_skip = temp_pos_set + if not self._pos_to_skip and self.DEFAULT_POS_TO_SKIP_NAMES: + log.warning("POS skip set is empty after initialization, though default names were provided. Check PartOfSpeech definitions.") + self.pos_init_status = self.STATUS_INITIALIZED + log.debug(f"POS skip set initialized: {self._pos_to_skip}") + + except Exception as e: + log.error(f"Error initializing Part-of-Speech skip set: {e}", exc_info=True) + self.pos_init_status = self.STATUS_FAILED + + def get_lemmas(self, text: str) -> Set[str]: + lemmas: Set[str] = set() + + if not self.mecab_controller or self.pos_init_status == self.STATUS_FAILED: + log.debug("MeCab controller not available or POS init failed. Skipping lemmatization.") + return lemmas + + if self.pos_init_status == self.STATUS_UNINITIALIZED: + log.warning("get_lemmas called while POS uninitialized. Attempting re-initialization.") + self._initialize_pos_skip_set() + if self.pos_init_status != self.STATUS_INITIALIZED: + log.error("Re-initialization of POS failed. Cannot get lemmas.") + return lemmas + + try: + tokens_raw = self.mecab_controller.translate(text) + if not isinstance(tokens_raw, (list, tuple)): + log.warning(f"MecabController.translate returned an unexpected type: {type(tokens_raw)}. Expected list or tuple.") + return lemmas + + for token_idx, token in enumerate(tokens_raw): + surface_word = getattr(token, 'word', '') + lemma_cand = getattr(token, 'headword', '') + raw_token_pos = getattr(token, 'part_of_speech', None) + token_pos_val: Optional[str] = None + + if raw_token_pos is not None: + if hasattr(raw_token_pos, 'value'): + token_pos_val = raw_token_pos.value + else: + log.warning(f"Token POS object {raw_token_pos} (type: {type(raw_token_pos)}) at index {token_idx} lacks .value. Using str().") + token_pos_val = str(raw_token_pos) + + if token_pos_val is not None and token_pos_val in self._pos_to_skip: + continue + + lemma_to_process = (lemma_cand or surface_word).strip() + if not lemma_to_process: continue + if lemma_to_process in self.combined_lemma_stop_list: continue + if len(lemma_to_process) == 1 and self.SINGLE_KANA_REGEX.fullmatch(lemma_to_process): continue + if self.FULLWIDTH_ALPHANUM_REGEX.fullmatch(lemma_to_process): continue + if lemma_to_process.isnumeric(): continue + if self.COUNTER_REGEX.fullmatch(lemma_to_process): continue + if lemma_to_process in self.UNWANTED_SYMBOLS: continue + + lemmas.add(lemma_to_process) + except Exception as e: + log.error(f"Error during lemmatization of text snippet '{text[:50]}...': {e}", exc_info=True) + return lemmas + + def test_mecab_and_pos(self) -> bool: + if not self.mecab_controller: + log.warning("MeCab test: Controller not available.") + return False + if self.pos_init_status == self.STATUS_FAILED: + log.warning("MeCab test: POS initialization failed.") + return False + if self.pos_init_status == self.STATUS_UNINITIALIZED: + log.info("MeCab test: POS uninitialized, attempting to initialize.") + self._initialize_pos_skip_set() + if self.pos_init_status != self.STATUS_INITIALIZED: + log.warning("MeCab test: POS re-initialization failed.") + return False + + test_passed = True + try: + lemmas_verb = self.get_lemmas("食べる") + if not lemmas_verb or "食べる" not in lemmas_verb: + log.warning(f"MeCab self-test (1): Failed to lemmatize '食べる' as expected. Got: {lemmas_verb}") + except Exception as e: + log.error(f"MeCab self-test (1): Error during lemmatization of '食べる': {e}", exc_info=True) + return False + + particle_pos_value = None + if hasattr(PartOfSpeech, 'particle') and hasattr(getattr(PartOfSpeech, 'particle'), 'value'): + particle_pos_value = getattr(PartOfSpeech, 'particle').value + + if self.pos_init_status == self.STATUS_INITIALIZED and particle_pos_value in self._pos_to_skip: + lemmas_particle = self.get_lemmas("猫が可愛い") + if "が" in lemmas_particle: + log.warning(f"MeCab self-test (2): Particle 'が' was not filtered. Got: {lemmas_particle}. Skipped POS: {self._pos_to_skip}") + + test_stopword = "する" + if test_stopword in self.combined_lemma_stop_list: + lemmas_stopword = self.get_lemmas(f"勉強{test_stopword}") + if test_stopword in lemmas_stopword: + log.warning(f"MeCab self-test (3): Stopword '{test_stopword}' was not filtered. Got: {lemmas_stopword}") + test_passed = False + + if test_passed: + log.info("MeCab self-test passed (basic functionality and critical filtering). Warnings may indicate sub-optimal configuration or MeCab variability.") + else: + log.warning("MeCab self-test indicated potential issues. Review warnings.") + return test_passed + + def update_stopwords(self, custom_stopwords_str: str, mode: str) -> None: + self._configure_stopwords(custom_stopwords_str, mode) + + +class KnownWordsProcessor: + def __init__(self, col: Any, mecab_processor: Optional[MeCabProcessor], progress_parent: Optional[QWidget] = None): + self.col = col + self.mecab_processor = mecab_processor + self.progress_parent = progress_parent + + def read_csv_data(self, filepath: str) -> Dict[str, Set[str]]: # Renamed from read_existing_csv + data: Dict[str, Set[str]] = {} + if not filepath or not os.path.exists(filepath): + if filepath: + showInfo(f"Specified CSV file not found: {os.path.basename(filepath)}") + return {} + try: + with open(filepath, 'r', encoding='utf-8', newline='') as f: + reader = csv.reader(f) + try: + header = next(reader) + except StopIteration: + showInfo(f"CSV file '{os.path.basename(filepath)}' is empty.") + return {} + + if not header or len(header) < 2 or \ + header[0].lower().strip() != CSV_COLUMN_WORD.lower() or \ + header[1].lower().strip() != CSV_COLUMN_SOURCE.lower(): + showInfo(f"CSV file '{os.path.basename(filepath)}' has an invalid header. " + f"Expected '{CSV_COLUMN_WORD}' and '{CSV_COLUMN_SOURCE}'.") + return {} + + for row_idx, row in enumerate(reader, 1): + if not row or len(row) < 1 or not row[0].strip(): + continue + word = row[0].strip() + sources_str = (row[1].strip() if len(row) > 1 and row[1] else "") + data[word] = set(s.strip() for s in sources_str.split(',') if s.strip()) + except (OSError, csv.Error) as e: # Changed IOError to OSError for consistency + log.error(f"Error reading CSV file '{os.path.basename(filepath)}': {e}", exc_info=True) + showInfo(f"Error reading CSV file '{os.path.basename(filepath)}':\n{e}") + return {} + except Exception as e: + log.error(f"Unexpected error reading CSV file '{os.path.basename(filepath)}': {e}", exc_info=True) + showInfo(f"An unexpected error occurred while reading CSV file '{os.path.basename(filepath)}':\n{e}") + return {} + return data + + def get_anki_data(self, query: str, sentence_field_name: str, + mature_interval: int, use_lemmatization: bool) -> Set[str]: # Renamed from _get_anki_data + anki_items: Set[str] = set() + if not query: + log.info("get_anki_data: Query is empty, returning no items.") + return anki_items + + try: + note_ids: List[int] = self.col.find_notes(query) + except Exception as e: + log.error(f"Error finding notes with query '{query}': {e}", exc_info=True) + showInfo(f"Error searching Anki notes (check query syntax in logs):\n{e}") + return anki_items + + if not note_ids: + log.info(f"get_anki_data: No notes found for query '{query}'.") + return anki_items + + # Use self.progress_parent if available for the QProgressDialog + progress_dialog_parent = self.progress_parent if self.progress_parent else mw + progress = QProgressDialog( + f"Scanning Anki notes (field: '{sentence_field_name}')...", + "Cancel", 0, len(note_ids), + progress_dialog_parent # Parent appropriately + ) + progress.setWindowModality(Qt.WindowModality.WindowModal) + progress.setValue(0) + + processed_count = 0 + for idx, nid in enumerate(note_ids): + if idx % 50 == 0: + progress.setValue(idx) + QApplication.processEvents() + + if progress.wasCanceled(): + showInfo("Operation cancelled by user.") + return set() + + note = self.col.get_note(nid) + if not note: + log.warning(f"Could not retrieve note with nid {nid}. Skipping.") + continue + if sentence_field_name not in note: + log.debug(f"Field '{sentence_field_name}' not in note {nid} (type: {note.note_type()['name'] if note.note_type() else 'Unknown'}). Skipping.") + continue + + is_mature_enough = False + if mature_interval > 0: + cards = note.cards() + if not cards: + is_mature_enough = False + else: + for card in cards: + if card.ivl >= mature_interval: + is_mature_enough = True + break + if not is_mature_enough: + continue + + text_raw = note[sentence_field_name] + if not text_raw or not text_raw.strip(): + continue + text_cleaned = strip_html_media(text_raw) + + if use_lemmatization and self.mecab_processor and \ + self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_INITIALIZED: # Stricter check + try: + lemmas_from_field = self.mecab_processor.get_lemmas(text_cleaned) + anki_items.update(lemmas_from_field) + except Exception as e: + log.error(f"Error lemmatizing field content from note {nid}: {e}", exc_info=True) + else: + temp_val = re.sub(r'\s*[((].*?[))]\s*|\s*\[.*?\]\s*|[\uff08\uff09\u3010\u3011\u300c\u300d\u300e\u300f]', '', text_cleaned).strip() + words_from_field = re.split(r'[\s\uff0c\u3001\uff0e\u3002\uff1f\uff01\uff1b\uff1a?!”;:]+', temp_val) + anki_items.update(word for word in words_from_field if word) + processed_count +=1 + + progress.setValue(len(note_ids)) + log.info(f"Processed {processed_count}/{len(note_ids)} notes. Extracted {len(anki_items)} unique items from Anki.") + return anki_items + + def merge_data(self, existing_csv_data: Dict[str, Set[str]], + anki_items: Set[str]) -> Tuple[Dict[str, Set[str]], Dict[str, int]]: # Renamed from _merge_csv_data + merged_data = {k: set(v) for k, v in existing_csv_data.items()} + stats = { + "anki_source_removed_not_in_anki": 0, + "word_deleted_no_sources_left": 0, + "new_word_from_anki_added": 0, + "anki_source_added_to_existing_word": 0 + } + + for word in list(merged_data.keys()): + sources = merged_data[word] + if ANKI_SOURCE_TAG in sources and word not in anki_items: + sources.remove(ANKI_SOURCE_TAG) + stats["anki_source_removed_not_in_anki"] += 1 + if not sources: + del merged_data[word] + stats["word_deleted_no_sources_left"] += 1 + elif not sources: + del merged_data[word] + + for item in anki_items: + if item in merged_data: + if ANKI_SOURCE_TAG not in merged_data[item]: + merged_data[item].add(ANKI_SOURCE_TAG) + stats["anki_source_added_to_existing_word"] +=1 + else: + merged_data[item] = {ANKI_SOURCE_TAG} + stats["new_word_from_anki_added"] += 1 + return merged_data, stats + + def write_csv_data(self, output_path: str, data_to_write: Dict[str, Set[str]]) -> bool: # Renamed from _write_csv_data + output_rows: List[List[str]] = [[CSV_COLUMN_WORD, CSV_COLUMN_SOURCE]] + for word, sources in sorted(data_to_write.items()): + if sources: + output_rows.append([word, ",".join(sorted(list(sources)))]) + + if len(output_rows) <= 1: + showInfo("No data to save (either no words found or all words had no sources).") + return False + + try: + with open(output_path, 'w', encoding='utf-8', newline='') as f: + csv_writer = csv.writer(f) + csv_writer.writerows(output_rows) + return True + except OSError as e: # Changed IOError to OSError + log.error(f"OSError writing CSV file to '{os.path.basename(output_path)}': {e}", exc_info=True) + showInfo(f"Error writing CSV file to '{os.path.basename(output_path)}':\n{e}") + return False + except Exception as e: + log.error(f"Unexpected error writing CSV to '{os.path.basename(output_path)}': {e}", exc_info=True) + showInfo(f"An unexpected error occurred while writing CSV to '{os.path.basename(output_path)}':\n{e}") + return False + + +class ExportVocabCsvDialog(QDialog): + # Class constants for UI related default values / preferences + DEFAULT_FIELD_SUGGESTION = "SentKanji" + PREFERRED_FIELDS_ORDER = [DEFAULT_FIELD_SUGGESTION, "VocabKanji", "Expression", "Sentence"] + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Manage Known Words") + self.setMinimumWidth(800) + + self.mecab_processor: Optional[MeCabProcessor] = None + if MECAB_AVAILABLE: + try: + addon_config = mw.addonManager.getConfig(__name__.split('.')[0]) if mw else {} + _initial_custom_stopwords = addon_config.get(CONFIG_KEY_CUSTOM_STOPWORDS, "") + _initial_stopwords_mode = addon_config.get(CONFIG_KEY_CUSTOM_STOPWORDS_MODE, STOPWORDS_MODE_SUPPLEMENT) + self.mecab_processor = MeCabProcessor( + custom_stopwords_str=_initial_custom_stopwords, + stopwords_mode=_initial_stopwords_mode + ) + except Exception as e: + log.error(f"Failed to initialize MeCabProcessor for dialog: {e}", exc_info=True) + self.mecab_processor = None + showInfo("Error initializing MeCab processor. Lemmatization may be unavailable. Check logs.") + + main_layout = QVBoxLayout(self) + main_layout.setSpacing(12) + + tab_widget = QTabWidget() + + tab1_widget = QWidget() + tab1_layout = QVBoxLayout(tab1_widget) + tab1_layout.setSpacing(12) + tab1_layout.setContentsMargins(8, 8, 8, 8) + + anki_source_groupbox = self._setup_anki_source_group() + csv_dictionary_groupbox = self._setup_csv_dictionary_group() + + tab1_layout.addWidget(anki_source_groupbox) + tab1_layout.addWidget(csv_dictionary_groupbox) + tab1_layout.addStretch(1) + tab1_widget.setLayout(tab1_layout) + tab_widget.addTab(tab1_widget, "Core Settings") + + tab2_widget = QWidget() + tab2_layout = QVBoxLayout(tab2_widget) + tab2_layout.setSpacing(12) + tab2_layout.setContentsMargins(8, 8, 8, 8) + + lemmatization_groupbox = self._setup_lemmatization_group() + mecab_test_groupbox = self._setup_mecab_test_group() + + tab2_layout.addWidget(lemmatization_groupbox) + tab2_layout.addWidget(mecab_test_groupbox) + tab2_layout.addStretch(1) + tab2_widget.setLayout(tab2_layout) + tab_widget.addTab(tab2_widget, "Lemmatization and Tools") + + buttons_layout = self._setup_buttons_layout() + + main_layout.addWidget(tab_widget) + main_layout.addLayout(buttons_layout) + self.setLayout(main_layout) + + self.load_settings() + self.update_operation_mode_state() + self._update_mecab_processor_stopwords() + self.update_dict_filter_state() + + def _setup_anki_source_group(self) -> QGroupBox: + groupbox = QGroupBox("Anki Data Source") + form_layout = QFormLayout() + form_layout.setSpacing(8) + form_layout.setContentsMargins(10, 10, 10, 10) + + self.note_type_filter_edit = QLineEdit() + self.note_type_filter_edit.setPlaceholderText("e.g., japanese, basic") + form_layout.addRow("Process Note Types Containing:", self.note_type_filter_edit) + + self.field_combo = QComboBox() + self.populate_field_combo() + form_layout.addRow("Sentence/Word Field:", self.field_combo) + + self.interval_spinbox = QSpinBox() + self.interval_spinbox.setMinimum(0) + self.interval_spinbox.setMaximum(9999) + self.interval_spinbox.setValue(DEFAULT_MATURE_INTERVAL) + self.interval_spinbox.setSuffix(" days") + form_layout.addRow("Mature Interval (>=):", self.interval_spinbox) + + groupbox.setLayout(form_layout) + return groupbox + + def _setup_csv_dictionary_group(self) -> QGroupBox: + groupbox = QGroupBox("CSV File and Dictionary Settings") + form_layout = QFormLayout() + form_layout.setSpacing(8) + form_layout.setContentsMargins(10, 10, 10, 10) + + csv_path_options_layout = QHBoxLayout() + csv_path_options_layout.setSpacing(6) + + self.csv_path_edit = QLineEdit() + self.csv_path_edit.setPlaceholderText("(Optional) Select CSV to read/update") + csv_path_options_layout.addWidget(self.csv_path_edit, 1) + + self.auto_timestamp_checkbox = QCheckBox("Timestamp new filenames") + self.auto_timestamp_checkbox.setToolTip("Append _YYYY-MM-DD_HHMMSS to new filenames.") + csv_path_options_layout.addWidget(self.auto_timestamp_checkbox) + + self.select_csv_button = QPushButton("Browse...") + self.select_csv_button.clicked.connect(self.select_existing_csv) + csv_path_options_layout.addWidget(self.select_csv_button) + form_layout.addRow("CSV File:", csv_path_options_layout) + self.csv_path_edit.textChanged.connect(self.update_operation_mode_state) + + self.operation_group = QButtonGroup(self) + self.radio_update_existing = QRadioButton("Update selected CSV directly") + self.radio_save_as_new = QRadioButton("Save results to a new/different CSV file") + self.operation_group.addButton(self.radio_update_existing) + self.operation_group.addButton(self.radio_save_as_new) + operation_layout = QVBoxLayout() + operation_layout.setContentsMargins(0,0,0,0) + operation_layout.setSpacing(4) + operation_layout.addWidget(self.radio_update_existing) + operation_layout.addWidget(self.radio_save_as_new) + self.radio_save_as_new.setChecked(True) + form_layout.addRow("CSV Operation:", operation_layout) + + line_separator = QFrame() + line_separator.setFrameShape(QFrame.Shape.HLine) + line_separator.setFrameShadow(QFrame.Shadow.Sunken) + form_layout.addRow(line_separator) + + + self.filter_by_dict_checkbox = QCheckBox("Filter extracted words against a dictionary file") + self.filter_by_dict_checkbox.setToolTip("Only include words/lemmas from Anki if they also exist in the specified dictionary file.") + self.filter_by_dict_checkbox.stateChanged.connect(self.update_dict_filter_state) + form_layout.addRow("", self.filter_by_dict_checkbox) + + self.dict_path_edit = QLineEdit() + self.dict_path_edit.setPlaceholderText("Path to dictionary (one word per line, .txt, .raw)") + self.select_dict_button = QPushButton("Browse...") + self.select_dict_button.clicked.connect(self.select_dictionary_file) + + self.dict_path_layout_container = QHBoxLayout() + self.dict_path_layout_container.setSpacing(6) + self.dict_path_layout_container.addWidget(self.dict_path_edit, 1) + self.dict_path_layout_container.addWidget(self.select_dict_button) + + self.dict_path_row_label = QLabel("Dictionary File:") + form_layout.addRow(self.dict_path_row_label, self.dict_path_layout_container) + + groupbox.setLayout(form_layout) + return groupbox + + def _setup_lemmatization_group(self) -> QGroupBox: + groupbox = QGroupBox("Lemmatization Settings") + form_layout = QFormLayout() + form_layout.setSpacing(8) + form_layout.setContentsMargins(10, 10, 10, 10) + + self.lemmatize_checkbox = QCheckBox("Extract lemmas using MeCab") + if self.is_lemmatization_module_available: + self.lemmatize_checkbox.setChecked(True) + self.lemmatize_checkbox.setToolTip("Process Anki field to extract dictionary form of words using MeCab.") + else: + self.lemmatize_checkbox.setChecked(False) + self.lemmatize_checkbox.setEnabled(False) + tooltip_msg = "MeCab components not found or Part-of-Speech initialization failed. Lemmatization disabled." + self.lemmatize_checkbox.setToolTip(tooltip_msg) + form_layout.addRow(self.lemmatize_checkbox) + + self.custom_stopwords_edit = QTextEdit() + self.custom_stopwords_edit.setPlaceholderText("Enter custom stopwords (one per line).\nThese supplement or replace built-in stopwords.") + self.custom_stopwords_edit.setMinimumHeight(80) + form_layout.addRow("Custom Stopwords:", self.custom_stopwords_edit) + + self.stopwords_mode_group = QButtonGroup(self) + self.stopwords_supplement_radio = QRadioButton("Supplement built-in stopwords") + self.stopwords_replace_radio = QRadioButton("Replace built-in stopwords") + self.stopwords_mode_group.addButton(self.stopwords_supplement_radio) + self.stopwords_mode_group.addButton(self.stopwords_replace_radio) + self.stopwords_supplement_radio.setChecked(True) + stopwords_mode_layout = QHBoxLayout() + stopwords_mode_layout.setSpacing(10) + stopwords_mode_layout.addWidget(self.stopwords_supplement_radio) + stopwords_mode_layout.addWidget(self.stopwords_replace_radio) + stopwords_mode_layout.addStretch() + self.stopwords_supplement_radio.clicked.connect(self._on_custom_stopwords_changed) + self.stopwords_replace_radio.clicked.connect(self._on_custom_stopwords_changed) + self.custom_stopwords_edit.textChanged.connect(self._on_custom_stopwords_changed) + form_layout.addRow("Stopwords Mode:", stopwords_mode_layout) + + groupbox.setLayout(form_layout) + groupbox.setEnabled(self.is_lemmatization_module_available) + return groupbox + + def _setup_mecab_test_group(self) -> QGroupBox: + groupbox = QGroupBox("MeCab Test Tool") + form_layout = QFormLayout() + form_layout.setSpacing(8) + form_layout.setContentsMargins(10, 10, 10, 10) + + self.mecab_test_input_edit = QLineEdit() + self.mecab_test_input_edit.setPlaceholderText("Enter Japanese text here...") + self.mecab_test_run_button = QPushButton("Test Lemmatization") + self.mecab_test_run_button.clicked.connect(self.on_mecab_test_run_clicked) + + input_button_layout = QHBoxLayout() + input_button_layout.setSpacing(6) + input_button_layout.addWidget(self.mecab_test_input_edit, 1) + input_button_layout.addWidget(self.mecab_test_run_button) + form_layout.addRow("Test Text:", input_button_layout) + + self.mecab_test_output_display = QTextEdit() + self.mecab_test_output_display.setReadOnly(True) + self.mecab_test_output_display.setPlaceholderText("Lemmatized output will appear here.") + self.mecab_test_output_display.setMinimumHeight(100) + form_layout.addRow("Output:", self.mecab_test_output_display) + + groupbox.setLayout(form_layout) + groupbox.setEnabled(self.is_lemmatization_module_available) + if not self.is_lemmatization_module_available: + groupbox.setToolTip("MeCab is not available or not initialized correctly.") + return groupbox + + def _setup_buttons_layout(self) -> QHBoxLayout: + layout = QHBoxLayout() + self.process_button = QPushButton("Process and Save CSV") + self.process_button.clicked.connect(self.on_process_button_clicked) + self.cancel_button = QPushButton("Cancel") + self.cancel_button.clicked.connect(self.reject) + + layout.addStretch() + layout.addWidget(self.process_button) + layout.addWidget(self.cancel_button) + return layout + + @property + def is_lemmatization_module_available(self) -> bool: + return (MECAB_AVAILABLE and + self.mecab_processor is not None and + self.mecab_processor.pos_init_status != MeCabProcessor.STATUS_FAILED) + + @property + def is_lemmatization_fully_functional(self) -> bool: + return (MECAB_AVAILABLE and + self.mecab_processor is not None and + self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_INITIALIZED) + + def _get_start_directory(self, current_path_text: str) -> str: + if current_path_text: + current_path_abs = os.path.abspath(current_path_text) + if os.path.isfile(current_path_abs): + return os.path.dirname(current_path_abs) + if os.path.isdir(current_path_abs): + return current_path_abs + if mw and mw.pm and hasattr(mw.pm, 'profileFolder') and mw.pm.profileFolder(): + return mw.pm.profileFolder() + return os.path.expanduser("~") + + def update_dict_filter_state(self) -> None: + is_checked = self.filter_by_dict_checkbox.isChecked() + self.dict_path_row_label.setVisible(is_checked) + self.dict_path_edit.setVisible(is_checked) + self.select_dict_button.setVisible(is_checked) + self.dict_path_edit.setEnabled(is_checked) + self.select_dict_button.setEnabled(is_checked) + + def select_dictionary_file(self) -> None: + start_dir = self._get_start_directory(self.dict_path_edit.text()) + path, _ = QFileDialog.getOpenFileName( + self, "Select Dictionary File", start_dir, "Text Files (*.txt *.raw);;All Files (*)" + ) + if path: + self.dict_path_edit.setText(path) + + def populate_field_combo(self) -> None: + if not mw or not mw.col: + log.warning("Cannot populate field combo: Anki collection not available.") + self.field_combo.addItem("Error: Anki collection not available") + self.field_combo.setEnabled(False) + return + + all_fields: Set[str] = set() + try: + for model in mw.col.models.all(): + for field_dict in model['flds']: + all_fields.add(field_dict['name']) + except Exception as e: + log.error(f"Error loading fields from Anki models: {e}", exc_info=True) + self.field_combo.addItem("Error loading fields") + self.field_combo.setEnabled(False) + return + + if not all_fields: + self.field_combo.addItem("No fields found") + self.field_combo.setEnabled(False) + return + + sorted_fields = sorted(list(all_fields)) + + current_selection = "" + for pf in self.PREFERRED_FIELDS_ORDER: + if pf in all_fields: + current_selection = pf + break + + if not current_selection and self.DEFAULT_FIELD_SUGGESTION in all_fields: + current_selection = self.DEFAULT_FIELD_SUGGESTION + + if not current_selection and sorted_fields: + current_selection = sorted_fields[0] + + self.field_combo.clear() + self.field_combo.addItems(sorted_fields) + + if current_selection: + idx = self.field_combo.findText(current_selection) + if idx != -1: + self.field_combo.setCurrentIndex(idx) + elif self.field_combo.count() > 0: + self.field_combo.setCurrentIndex(0) + elif self.field_combo.count() > 0: + self.field_combo.setCurrentIndex(0) + + + def update_operation_mode_state(self) -> None: + has_input_path = bool(self.csv_path_edit.text().strip()) + self.radio_update_existing.setEnabled(has_input_path) + if not has_input_path and self.radio_update_existing.isChecked(): + self.radio_save_as_new.setChecked(True) + + def _update_mecab_processor_stopwords(self) -> None: + if self.mecab_processor: + custom_stopwords = self.custom_stopwords_edit.toPlainText() + mode = STOPWORDS_MODE_REPLACE if self.stopwords_replace_radio.isChecked() else STOPWORDS_MODE_SUPPLEMENT + try: + self.mecab_processor.update_stopwords(custom_stopwords, mode) + except Exception as e: + log.error(f"Error updating MeCab processor stopwords: {e}", exc_info=True) + + def _on_custom_stopwords_changed(self) -> None: + self._update_mecab_processor_stopwords() + + def load_settings(self) -> None: + if not mw: + log.warning("Cannot load settings: Anki environment (mw) not available.") + return + + addon_package = __name__.split('.')[0] + config = mw.addonManager.getConfig(addon_package) or {} + + self.csv_path_edit.setText(config.get(CONFIG_KEY_LAST_CSV_PATH, "")) + note_type_filter = config.get(CONFIG_KEY_NOTE_TYPE_FILTER_STRING, DEFAULT_NOTE_TYPE_FILTER_STRING) + self.note_type_filter_edit.setText(note_type_filter) + last_field = config.get(CONFIG_KEY_LAST_FIELD, self.DEFAULT_FIELD_SUGGESTION) + idx_field = self.field_combo.findText(last_field) + if idx_field != -1: + self.field_combo.setCurrentIndex(idx_field) + elif self.field_combo.count() > 0: + if not self.field_combo.currentText(): + self.field_combo.setCurrentIndex(0) + + self.interval_spinbox.setValue(config.get(CONFIG_KEY_LAST_INTERVAL, DEFAULT_MATURE_INTERVAL)) + last_op_mode = config.get(CONFIG_KEY_LAST_OPERATION_MODE, OP_MODE_SAVE_AS_NEW) + if last_op_mode == OP_MODE_UPDATE_EXISTING and self.radio_update_existing.isEnabled(): + self.radio_update_existing.setChecked(True) + else: + self.radio_save_as_new.setChecked(True) + + if self.is_lemmatization_module_available: + self.lemmatize_checkbox.setChecked(config.get(CONFIG_KEY_LEMMATIZE, True)) + self.custom_stopwords_edit.setPlainText(config.get(CONFIG_KEY_CUSTOM_STOPWORDS, "")) + stopwords_mode = config.get(CONFIG_KEY_CUSTOM_STOPWORDS_MODE, STOPWORDS_MODE_SUPPLEMENT) + if stopwords_mode == STOPWORDS_MODE_REPLACE: + self.stopwords_replace_radio.setChecked(True) + else: + self.stopwords_supplement_radio.setChecked(True) + + self._update_mecab_processor_stopwords() + + self.auto_timestamp_checkbox.setChecked(config.get(CONFIG_KEY_AUTO_TIMESTAMP_FILENAME, False)) + self.filter_by_dict_checkbox.setChecked(config.get(CONFIG_KEY_FILTER_BY_DICT, False)) + self.dict_path_edit.setText(config.get(CONFIG_KEY_LAST_DICT_PATH, "")) + self.update_dict_filter_state() + + def save_settings(self) -> None: + if not mw: + log.warning("Cannot save settings: Anki environment (mw) not available.") + return + + addon_package = __name__.split('.')[0] + config = mw.addonManager.getConfig(addon_package) or {} + + config[CONFIG_KEY_LAST_CSV_PATH] = self.csv_path_edit.text() + config[CONFIG_KEY_NOTE_TYPE_FILTER_STRING] = self.note_type_filter_edit.text() + config[CONFIG_KEY_LAST_FIELD] = self.field_combo.currentText() + config[CONFIG_KEY_LAST_INTERVAL] = self.interval_spinbox.value() + config[CONFIG_KEY_LAST_OPERATION_MODE] = OP_MODE_UPDATE_EXISTING if self.radio_update_existing.isChecked() else OP_MODE_SAVE_AS_NEW + + if self.is_lemmatization_module_available: + config[CONFIG_KEY_LEMMATIZE] = self.lemmatize_checkbox.isChecked() + config[CONFIG_KEY_CUSTOM_STOPWORDS] = self.custom_stopwords_edit.toPlainText() + config[CONFIG_KEY_CUSTOM_STOPWORDS_MODE] = STOPWORDS_MODE_REPLACE if self.stopwords_replace_radio.isChecked() else STOPWORDS_MODE_SUPPLEMENT + else: + config.pop(CONFIG_KEY_LEMMATIZE, None) + config.pop(CONFIG_KEY_CUSTOM_STOPWORDS, None) + config.pop(CONFIG_KEY_CUSTOM_STOPWORDS_MODE, None) + + config[CONFIG_KEY_AUTO_TIMESTAMP_FILENAME] = self.auto_timestamp_checkbox.isChecked() + config[CONFIG_KEY_FILTER_BY_DICT] = self.filter_by_dict_checkbox.isChecked() + config[CONFIG_KEY_LAST_DICT_PATH] = self.dict_path_edit.text() + + try: + mw.addonManager.writeConfig(addon_package, config) + except Exception as e: + log.error(f"Error writing addon config: {e}", exc_info=True) + showInfo(f"Could not save settings: {e}") + + def select_existing_csv(self) -> None: + start_dir = self._get_start_directory(self.csv_path_edit.text()) + path, _ = QFileDialog.getOpenFileName(self, "Select CSV File", start_dir, "CSV Files (*.csv);;All Files (*)") + if path: + self.csv_path_edit.setText(path) + + # Method _determine_output_path remains in ExportVocabCsvDialog as it directly uses UI elements + # for QFileDialog and radio button states. + def _determine_output_path(self) -> Optional[str]: + input_csv_path = self.csv_path_edit.text().strip() + output_path: Optional[str] = None + + if self.radio_update_existing.isChecked(): + if not input_csv_path: + showInfo("Cannot update: No input CSV file specified.") + return None + if not os.path.exists(input_csv_path) or not os.path.isfile(input_csv_path): + showInfo(f"Cannot update: Specified input CSV file does not exist or is not a file:\n{input_csv_path}") + return None + output_path = input_csv_path + elif self.radio_save_as_new.isChecked(): + start_dir = self._get_start_directory(input_csv_path) + suggested_filename_base = DEFAULT_SAVE_FILENAME + if input_csv_path: + if os.path.isfile(input_csv_path): + suggested_filename_base = os.path.basename(input_csv_path) + elif os.path.isdir(input_csv_path): + start_dir = input_csv_path + + initial_file_path_suggestion = os.path.join(start_dir, suggested_filename_base) + filter_string_for_dialog = "CSV Files (*.csv);;All Files (*)" + raw_path, _ = QFileDialog.getSaveFileName( + self, "Save Known Words CSV", initial_file_path_suggestion, filter_string_for_dialog + ) # QFileDialog needs a parent, `self` (the dialog) is appropriate + + if not raw_path: + return None + + name, ext = os.path.splitext(raw_path) + if ext.lower() != ".csv": + raw_path = name + ".csv" + + if self.auto_timestamp_checkbox.isChecked(): + name_no_ext, current_ext = os.path.splitext(raw_path) + timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M%S") + output_path = f"{name_no_ext}_{timestamp}{current_ext}" + else: + output_path = raw_path + + if output_path and os.path.splitext(output_path)[1].lower() != ".csv": + output_path = os.path.splitext(output_path)[0] + ".csv" + log.warning(f"Output path extension corrected to .csv: {output_path}") + return output_path + + def on_process_button_clicked(self) -> None: + if not mw or not mw.col: + log.critical("Anki collection (mw.col) is not available. Cannot proceed.") + showInfo("Critical error: Anki collection (mw.col) is not available. Cannot proceed.") + return + + # Instantiate the processor, passing `self` as progress_parent + processor = KnownWordsProcessor(mw.col, self.mecab_processor, progress_parent=self) + + note_type_filter_text = self.note_type_filter_edit.text().strip() + if not note_type_filter_text: + showInfo("Please enter a filter string for note type names (e.g., 'japanese'). Cannot be empty.") + return + + normalized_filter = note_type_filter_text.lower() + try: + all_note_type_infos = mw.col.models.all_names_and_ids() + except Exception as e: + log.error(f"Error fetching note type names and IDs: {e}", exc_info=True) + showInfo(f"Could not retrieve note type information from Anki: {e}") + return + + if not all_note_type_infos: + showInfo("No note types found in your Anki collection.") + return + + matching_model_names = [] + for nt_info in all_note_type_infos: + if normalized_filter in nt_info.name.lower(): + matching_model_names.append(nt_info.name) + + if not matching_model_names: + showInfo(f"No note types found with names containing: '{note_type_filter_text}'.") + return + + note_type_queries = [f'note:"{name.replace(_SINGLE_DBL_QUOTE, _ESCAPED_DBL_QUOTE)}"' for name in matching_model_names] + anki_query = f"({' OR '.join(note_type_queries)})" + log.info(f"Constructed Anki query: {anki_query}") + + input_csv_path = self.csv_path_edit.text().strip() + selected_field_name = self.field_combo.currentText() + mature_interval = self.interval_spinbox.value() + # Check for lemmatization fully functional (stricter) before enabling + use_lemmatization = (self.is_lemmatization_fully_functional and + self.lemmatize_checkbox.isChecked()) + + use_dictionary_filter = self.filter_by_dict_checkbox.isChecked() + dictionary_path = self.dict_path_edit.text().strip() + dictionary_set: Set[str] = set() + + if not selected_field_name or selected_field_name in ["No fields found", "Error loading fields", "Error: Anki collection not available"]: + showInfo("Please select a valid Anki Field to process.") + return + + if use_dictionary_filter: + if not dictionary_path: + showInfo("Dictionary filtering is enabled, but no dictionary file path is specified.") + return + if not os.path.exists(dictionary_path): + showInfo(f"Dictionary file not found at: {dictionary_path}") + return + dictionary_set = _load_dictionary_file(dictionary_path) # _load_dictionary_file is a module-level function + if not dictionary_set and os.path.exists(dictionary_path): + showInfo(f"The dictionary file '{os.path.basename(dictionary_path)}' was found but is empty or failed to load. " + "Filtering against an empty dictionary will result in no words from Anki being included.") + + if use_lemmatization and self.mecab_processor: # mecab_processor must exist for use_lemmatization to be true + self._update_mecab_processor_stopwords() # Dialog method updates its mecab_processor instance + if not self.mecab_processor.test_mecab_and_pos(): + tooltip("Warning: MeCab self-test failed. Lemmatization results may be inaccurate. Check add-on logs for details.", period=7000, parent=self) + + existing_csv_data: Dict[str, Set[str]] = {} + if input_csv_path: + if self.radio_update_existing.isChecked() and not os.path.exists(input_csv_path): + showInfo(f"Update operation selected, but the CSV file does not exist: {input_csv_path}") + return + existing_csv_data = processor.read_csv_data(input_csv_path) # Use processor instance + + anki_items_raw = processor.get_anki_data( + anki_query, selected_field_name, + mature_interval, use_lemmatization + ) + + anki_items_final: Set[str] + filtered_out_by_dict_count = 0 + if use_dictionary_filter: + original_count = len(anki_items_raw) + anki_items_final = {item for item in anki_items_raw if item in dictionary_set} + filtered_out_by_dict_count = original_count - len(anki_items_final) + if not anki_items_final and original_count > 0: + showInfo(f"No Anki items matched the words in the provided dictionary. " + f"{original_count} items were initially extracted from Anki; all {filtered_out_by_dict_count} were filtered out by the dictionary.") + else: + anki_items_final = anki_items_raw + + if not anki_items_final and not existing_csv_data: + message = "No Anki items to process (none found matching criteria" + if use_dictionary_filter and anki_items_raw: + message += ", or all filtered out by dictionary" + message += ") and no existing CSV data to merge/update. Nothing to save." + showInfo(message) + return + + merged_data, merge_stats = processor.merge_data(existing_csv_data, anki_items_final) # Use processor instance + + output_path = self._determine_output_path() # This dialog method remains as it needs UI state + if not output_path: + tooltip("Save operation cancelled or output path could not be determined.", parent=self) + return + + if processor.write_csv_data(output_path, merged_data): # Use processor instance + num_words_saved = len(merged_data) + stats_summary_parts = [ + f"New from Anki: {merge_stats['new_word_from_anki_added']}", + f"Anki source added: {merge_stats['anki_source_added_to_existing_word']}", + f"Anki source removed (no longer in Anki selection): {merge_stats['anki_source_removed_not_in_anki']}", + f"Words deleted (no sources left): {merge_stats['word_deleted_no_sources_left']}" + ] + if use_dictionary_filter: + stats_summary_parts.append(f"Filtered out by dictionary: {filtered_out_by_dict_count}") + stats_summary = ". ".join(stats_summary_parts) + "." + + parent_widget_for_final_info = self if self.isVisible() else mw + showInfo(f"Successfully saved {num_words_saved} words to:\n{os.path.basename(output_path)}\n\nSummary:\n{stats_summary}", + parent=parent_widget_for_final_info) + + if output_path.lower() != self.csv_path_edit.text().strip().lower(): + self.csv_path_edit.setText(output_path) + self.update_operation_mode_state() + + self.save_settings() + self.accept() + + def on_mecab_test_run_clicked(self) -> None: + test_text = self.mecab_test_input_edit.text().strip() + self.mecab_test_output_display.clear() + + if not self.mecab_processor: + msg = "MeCab processor is not available (MeCab components might be missing or failed to load). Lemmatization features disabled." + self.mecab_test_output_display.setText(msg) + log.warning("Mecab test: mecab_processor is None.") + return + + if not self.is_lemmatization_fully_functional: + msg = "MeCab is not fully functional for testing. " + if not MECAB_AVAILABLE: msg += "MeCab components are not installed. " + elif self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_FAILED: msg += "Part-of-Speech initialization failed. " + elif self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_UNINITIALIZED: + msg += "Part-of-Speech uninitialized. Attempting re-initialization..." + log.info("MeCab test: POS uninitialized, attempting re-init.") + self.mecab_processor._initialize_pos_skip_set() + if self.is_lemmatization_fully_functional: + msg += "\nRe-initialization successful. Try testing again." + else: + msg += "\nRe-initialization failed. Check logs for details." + else: + msg += "Current status: " + self.mecab_processor.pos_init_status + ". Check logs." + + self.mecab_test_output_display.setText(msg) + log.warning(f"Mecab test: Not fully functional. Status: {self.mecab_processor.pos_init_status}") + return + + if not test_text: + self.mecab_test_output_display.setText("Please enter some Japanese text to test.") + return + + try: + self._update_mecab_processor_stopwords() + lemmas = self.mecab_processor.get_lemmas(test_text) + if lemmas: + self.mecab_test_output_display.setText("\n".join(sorted(list(lemmas)))) + else: + self.mecab_test_output_display.setText("No lemmas extracted. Input might be empty after processing, " + "all tokens might be stopwords, or filtered by Part-of-Speech rules.") + except Exception as e: + log.error(f"An error occurred during MeCab lemmatization test: {e}", exc_info=True) + self.mecab_test_output_display.setText(f"An error occurred during lemmatization test:\n{e}") + +def show_export_vocab_csv_dialog(): + if not mw: + log.error("Cannot show dialog: Anki environment (mw) not available.") + showInfo("Anki environment (mw) not available. Cannot open dialog.") + return + dialog = ExportVocabCsvDialog(mw) + dialog.exec() From 5a810c0b1fd8b6e4d01785dbf1340aafcc5f3f49 Mon Sep 17 00:00:00 2001 From: Alberto Trigo Date: Thu, 8 May 2025 22:14:02 +0200 Subject: [PATCH 02/10] Create __init__.py --- japanese/known_words/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 japanese/known_words/__init__.py diff --git a/japanese/known_words/__init__.py b/japanese/known_words/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/japanese/known_words/__init__.py @@ -0,0 +1 @@ + From 981e7608109bf4d4a24f411cad7a5f80471e6cf3 Mon Sep 17 00:00:00 2001 From: Alberto Trigo Date: Thu, 8 May 2025 22:16:00 +0200 Subject: [PATCH 03/10] Update gui.py --- japanese/gui.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/japanese/gui.py b/japanese/gui.py index d8daddde..23cf161d 100644 --- a/japanese/gui.py +++ b/japanese/gui.py @@ -56,10 +56,19 @@ from .widgets.svg_settings import SvgSettingsWidget from .widgets.widgets_to_config_dict import as_config_dict +from .known_words.main import ExportVocabCsvDialog + + EDIT_MIN_WIDTH = 100 EXAMPLE_DECK_ANKIWEB_URL = "https://ankiweb.net/shared/info/1557722832" ADDON_SETUP_GUIDE = "https://tatsumoto-ren.github.io/blog/anki-japanese-support.html" +def show_export_vocab_csv_dialog(): + if not mw: # pragma: no cover + return + dialog = ExportVocabCsvDialog(mw) + dialog.exec() + def adjust_to_contents(widget: QWidget): try: @@ -678,6 +687,12 @@ def add_deck_download_action(root_menu: QMenu): def init(): root_menu = menu_root_entry() + + + export_action = QAction("Export Known Vocab", root_menu) + qconnect(export_action.triggered, show_export_vocab_csv_dialog) + root_menu.addAction(export_action) + add_settings_action(root_menu) add_deck_download_action(root_menu) set_config_action(lambda: SettingsDialog(mw)) From 2af2a5c513ab166d3ba2179526111920fd163ea3 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Fri, 9 May 2025 19:55:42 +0300 Subject: [PATCH 04/10] update submodules --- japanese/ajt_common | 2 +- japanese/mecab_controller | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/japanese/ajt_common b/japanese/ajt_common index 36c33804..ad395c22 160000 --- a/japanese/ajt_common +++ b/japanese/ajt_common @@ -1 +1 @@ -Subproject commit 36c3380473df68e4de1c7283b39ea46b2551b288 +Subproject commit ad395c22c50437353bfd6733ce9043924c8edaa8 diff --git a/japanese/mecab_controller b/japanese/mecab_controller index f6b651e5..70c927d5 160000 --- a/japanese/mecab_controller +++ b/japanese/mecab_controller @@ -1 +1 @@ -Subproject commit f6b651e596b3151230a0a4b79ed152048ac6c740 +Subproject commit 70c927d5f8be0bd051c64c7b0ff285c5dddd8d4b From b6396c37e07a74e6404b32f9fbb9033dafb9d9f3 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Fri, 9 May 2025 19:59:45 +0300 Subject: [PATCH 05/10] format code --- japanese/gui.py | 10 +- japanese/known_words/main.py | 386 +++++++++++++++++++++-------------- 2 files changed, 242 insertions(+), 154 deletions(-) diff --git a/japanese/gui.py b/japanese/gui.py index 23cf161d..8f6eb136 100644 --- a/japanese/gui.py +++ b/japanese/gui.py @@ -36,6 +36,7 @@ ProfilePitch, TaskCaller, ) +from .known_words.main import ExportVocabCsvDialog from .note_types import prepare_note_types from .pitch_accents.consts import USER_DATA_CSV_PATH from .reading import acc_dict @@ -56,15 +57,13 @@ from .widgets.svg_settings import SvgSettingsWidget from .widgets.widgets_to_config_dict import as_config_dict -from .known_words.main import ExportVocabCsvDialog - - EDIT_MIN_WIDTH = 100 EXAMPLE_DECK_ANKIWEB_URL = "https://ankiweb.net/shared/info/1557722832" ADDON_SETUP_GUIDE = "https://tatsumoto-ren.github.io/blog/anki-japanese-support.html" + def show_export_vocab_csv_dialog(): - if not mw: # pragma: no cover + if not mw: # pragma: no cover return dialog = ExportVocabCsvDialog(mw) dialog.exec() @@ -688,11 +687,10 @@ def add_deck_download_action(root_menu: QMenu): def init(): root_menu = menu_root_entry() - export_action = QAction("Export Known Vocab", root_menu) qconnect(export_action.triggered, show_export_vocab_csv_dialog) root_menu.addAction(export_action) - + add_settings_action(root_menu) add_deck_download_action(root_menu) set_config_action(lambda: SettingsDialog(mw)) diff --git a/japanese/known_words/main.py b/japanese/known_words/main.py index e824b81f..ef3a8c46 100644 --- a/japanese/known_words/main.py +++ b/japanese/known_words/main.py @@ -1,22 +1,35 @@ -import re import csv -import os import datetime import logging +import os +import re +from typing import Any, Dict, List, Optional, Set, Tuple -from typing import Set, List, Dict, Optional, Any, Tuple - +from anki.utils import strip_html_media from aqt import mw from aqt.qt import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, Qt, - QComboBox, QSpinBox, QFormLayout, QFileDialog, QLineEdit, - QRadioButton, QButtonGroup, QCheckBox, QTextEdit, QGroupBox, - QApplication, QWidget, QTabWidget, + QApplication, + QButtonGroup, + QCheckBox, + QComboBox, + QDialog, + QFileDialog, + QFormLayout, QFrame, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QRadioButton, + QSpinBox, + Qt, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, ) -from aqt.utils import showInfo, QProgressDialog, tooltip -from anki.utils import strip_html_media - +from aqt.utils import QProgressDialog, showInfo, tooltip # --- Configuration Keys --- CONFIG_KEY_LAST_CSV_PATH = "knownWordsCsvLastPath" @@ -59,24 +72,27 @@ try: from ..mecab_controller import MecabController from ..mecab_controller.basic_types import PartOfSpeech + MECAB_AVAILABLE = True except ImportError: MECAB_AVAILABLE = False log.warning("MecabController or basic_types not found. Lemmatization will be disabled.") - class MecabController: # type: ignore + class MecabController: # type: ignore def translate(self, text: str) -> list: return [] class _DummyPosValue: def __init__(self, value: str): self.value = value + def __str__(self) -> str: return self.value + def __repr__(self) -> str: return f"_DummyPosValue('{self.value}')" - class DummyPartOfSpeech: # type: ignore + class DummyPartOfSpeech: # type: ignore particle = _DummyPosValue("DUMMY_POS_particle") bound_auxiliary = _DummyPosValue("DUMMY_POS_bound_auxiliary") symbol = _DummyPosValue("DUMMY_POS_symbol") @@ -98,18 +114,19 @@ def __getattr__(self, name: str) -> _DummyPosValue: PartOfSpeech = DummyPartOfSpeech() + def _load_dictionary_file(filepath: str) -> Set[str]: dictionary_set: Set[str] = set() if not filepath or not os.path.exists(filepath): return dictionary_set try: - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, "r", encoding="utf-8") as f: for line in f: word = line.strip() if word: dictionary_set.add(word) - except (OSError, UnicodeDecodeError) as e: # MODIFIED: IOError to OSError + except (OSError, UnicodeDecodeError) as e: # MODIFIED: IOError to OSError log.error(f"Error reading dictionary file '{os.path.basename(filepath)}': {e}", exc_info=True) showInfo(f"Error reading dictionary file '{os.path.basename(filepath)}':\n{e}") except Exception as e: @@ -124,12 +141,16 @@ class MeCabProcessor: STATUS_FAILED = "FAILED" DEFAULT_POS_TO_SKIP_NAMES: List[str] = [ - "particle", "bound_auxiliary", "symbol", "filler", - "interjection", "prefix", + "particle", + "bound_auxiliary", + "symbol", + "filler", + "interjection", + "prefix", ] - SINGLE_KANA_REGEX = re.compile(r'^[\u3040-\u309F\u30A0-\u30FF]$') - FULLWIDTH_ALPHANUM_REGEX = re.compile(r'^[A-Za-z0-9]+$') - COUNTER_REGEX = re.compile(r'^\d+[つ個台本枚匹頭羽人日円年々ヵヶ箇カ]$') + SINGLE_KANA_REGEX = re.compile(r"^[\u3040-\u309F\u30A0-\u30FF]$") + FULLWIDTH_ALPHANUM_REGEX = re.compile(r"^[A-Za-z0-9]+$") + COUNTER_REGEX = re.compile(r"^\d+[つ個台本枚匹頭羽人日円年々ヵヶ箇カ]$") UNWANTED_SYMBOLS: Set[str] = {"「", "」", "、", "。", "?", "!", ":", ";", "(", ")", "【", "】", "『", "』"} def __init__(self, custom_stopwords_str: str = "", stopwords_mode: str = STOPWORDS_MODE_SUPPLEMENT): @@ -170,16 +191,22 @@ def _initialize_pos_skip_set(self) -> None: for pos_name in self.DEFAULT_POS_TO_SKIP_NAMES: if hasattr(PartOfSpeech, pos_name): pos_attr = getattr(PartOfSpeech, pos_name) - if hasattr(pos_attr, 'value'): + if hasattr(pos_attr, "value"): temp_pos_set.add(pos_attr.value) else: - log.warning(f"PartOfSpeech attribute '{pos_name}' (type: {type(pos_attr)}) lacks a '.value' attribute. Skipping.") + log.warning( + f"PartOfSpeech attribute '{pos_name}' (type: {type(pos_attr)}) lacks a '.value' attribute. Skipping." + ) else: - log.warning(f"PartOfSpeech enum (real or dummy) does not have attribute '{pos_name}'. Skipping for POS filter.") + log.warning( + f"PartOfSpeech enum (real or dummy) does not have attribute '{pos_name}'. Skipping for POS filter." + ) self._pos_to_skip = temp_pos_set if not self._pos_to_skip and self.DEFAULT_POS_TO_SKIP_NAMES: - log.warning("POS skip set is empty after initialization, though default names were provided. Check PartOfSpeech definitions.") + log.warning( + "POS skip set is empty after initialization, though default names were provided. Check PartOfSpeech definitions." + ) self.pos_init_status = self.STATUS_INITIALIZED log.debug(f"POS skip set initialized: {self._pos_to_skip}") @@ -204,33 +231,44 @@ def get_lemmas(self, text: str) -> Set[str]: try: tokens_raw = self.mecab_controller.translate(text) if not isinstance(tokens_raw, (list, tuple)): - log.warning(f"MecabController.translate returned an unexpected type: {type(tokens_raw)}. Expected list or tuple.") - return lemmas + log.warning( + f"MecabController.translate returned an unexpected type: {type(tokens_raw)}. Expected list or tuple." + ) + return lemmas for token_idx, token in enumerate(tokens_raw): - surface_word = getattr(token, 'word', '') - lemma_cand = getattr(token, 'headword', '') - raw_token_pos = getattr(token, 'part_of_speech', None) + surface_word = getattr(token, "word", "") + lemma_cand = getattr(token, "headword", "") + raw_token_pos = getattr(token, "part_of_speech", None) token_pos_val: Optional[str] = None if raw_token_pos is not None: - if hasattr(raw_token_pos, 'value'): + if hasattr(raw_token_pos, "value"): token_pos_val = raw_token_pos.value else: - log.warning(f"Token POS object {raw_token_pos} (type: {type(raw_token_pos)}) at index {token_idx} lacks .value. Using str().") + log.warning( + f"Token POS object {raw_token_pos} (type: {type(raw_token_pos)}) at index {token_idx} lacks .value. Using str()." + ) token_pos_val = str(raw_token_pos) - + if token_pos_val is not None and token_pos_val in self._pos_to_skip: continue lemma_to_process = (lemma_cand or surface_word).strip() - if not lemma_to_process: continue - if lemma_to_process in self.combined_lemma_stop_list: continue - if len(lemma_to_process) == 1 and self.SINGLE_KANA_REGEX.fullmatch(lemma_to_process): continue - if self.FULLWIDTH_ALPHANUM_REGEX.fullmatch(lemma_to_process): continue - if lemma_to_process.isnumeric(): continue - if self.COUNTER_REGEX.fullmatch(lemma_to_process): continue - if lemma_to_process in self.UNWANTED_SYMBOLS: continue + if not lemma_to_process: + continue + if lemma_to_process in self.combined_lemma_stop_list: + continue + if len(lemma_to_process) == 1 and self.SINGLE_KANA_REGEX.fullmatch(lemma_to_process): + continue + if self.FULLWIDTH_ALPHANUM_REGEX.fullmatch(lemma_to_process): + continue + if lemma_to_process.isnumeric(): + continue + if self.COUNTER_REGEX.fullmatch(lemma_to_process): + continue + if lemma_to_process in self.UNWANTED_SYMBOLS: + continue lemmas.add(lemma_to_process) except Exception as e: @@ -261,23 +299,27 @@ def test_mecab_and_pos(self) -> bool: return False particle_pos_value = None - if hasattr(PartOfSpeech, 'particle') and hasattr(getattr(PartOfSpeech, 'particle'), 'value'): - particle_pos_value = getattr(PartOfSpeech, 'particle').value - + if hasattr(PartOfSpeech, "particle") and hasattr(getattr(PartOfSpeech, "particle"), "value"): + particle_pos_value = getattr(PartOfSpeech, "particle").value + if self.pos_init_status == self.STATUS_INITIALIZED and particle_pos_value in self._pos_to_skip: lemmas_particle = self.get_lemmas("猫が可愛い") if "が" in lemmas_particle: - log.warning(f"MeCab self-test (2): Particle 'が' was not filtered. Got: {lemmas_particle}. Skipped POS: {self._pos_to_skip}") - + log.warning( + f"MeCab self-test (2): Particle 'が' was not filtered. Got: {lemmas_particle}. Skipped POS: {self._pos_to_skip}" + ) + test_stopword = "する" if test_stopword in self.combined_lemma_stop_list: lemmas_stopword = self.get_lemmas(f"勉強{test_stopword}") if test_stopword in lemmas_stopword: log.warning(f"MeCab self-test (3): Stopword '{test_stopword}' was not filtered. Got: {lemmas_stopword}") test_passed = False - + if test_passed: - log.info("MeCab self-test passed (basic functionality and critical filtering). Warnings may indicate sub-optimal configuration or MeCab variability.") + log.info( + "MeCab self-test passed (basic functionality and critical filtering). Warnings may indicate sub-optimal configuration or MeCab variability." + ) else: log.warning("MeCab self-test indicated potential issues. Review warnings.") return test_passed @@ -292,35 +334,40 @@ def __init__(self, col: Any, mecab_processor: Optional[MeCabProcessor], progress self.mecab_processor = mecab_processor self.progress_parent = progress_parent - def read_csv_data(self, filepath: str) -> Dict[str, Set[str]]: # Renamed from read_existing_csv + def read_csv_data(self, filepath: str) -> Dict[str, Set[str]]: # Renamed from read_existing_csv data: Dict[str, Set[str]] = {} if not filepath or not os.path.exists(filepath): if filepath: showInfo(f"Specified CSV file not found: {os.path.basename(filepath)}") return {} try: - with open(filepath, 'r', encoding='utf-8', newline='') as f: + with open(filepath, "r", encoding="utf-8", newline="") as f: reader = csv.reader(f) try: header = next(reader) except StopIteration: showInfo(f"CSV file '{os.path.basename(filepath)}' is empty.") return {} - - if not header or len(header) < 2 or \ - header[0].lower().strip() != CSV_COLUMN_WORD.lower() or \ - header[1].lower().strip() != CSV_COLUMN_SOURCE.lower(): - showInfo(f"CSV file '{os.path.basename(filepath)}' has an invalid header. " - f"Expected '{CSV_COLUMN_WORD}' and '{CSV_COLUMN_SOURCE}'.") + + if ( + not header + or len(header) < 2 + or header[0].lower().strip() != CSV_COLUMN_WORD.lower() + or header[1].lower().strip() != CSV_COLUMN_SOURCE.lower() + ): + showInfo( + f"CSV file '{os.path.basename(filepath)}' has an invalid header. " + f"Expected '{CSV_COLUMN_WORD}' and '{CSV_COLUMN_SOURCE}'." + ) return {} for row_idx, row in enumerate(reader, 1): if not row or len(row) < 1 or not row[0].strip(): continue word = row[0].strip() - sources_str = (row[1].strip() if len(row) > 1 and row[1] else "") - data[word] = set(s.strip() for s in sources_str.split(',') if s.strip()) - except (OSError, csv.Error) as e: # Changed IOError to OSError for consistency + sources_str = row[1].strip() if len(row) > 1 and row[1] else "" + data[word] = set(s.strip() for s in sources_str.split(",") if s.strip()) + except (OSError, csv.Error) as e: # Changed IOError to OSError for consistency log.error(f"Error reading CSV file '{os.path.basename(filepath)}': {e}", exc_info=True) showInfo(f"Error reading CSV file '{os.path.basename(filepath)}':\n{e}") return {} @@ -330,8 +377,9 @@ def read_csv_data(self, filepath: str) -> Dict[str, Set[str]]: # Renamed from re return {} return data - def get_anki_data(self, query: str, sentence_field_name: str, - mature_interval: int, use_lemmatization: bool) -> Set[str]: # Renamed from _get_anki_data + def get_anki_data( + self, query: str, sentence_field_name: str, mature_interval: int, use_lemmatization: bool + ) -> Set[str]: # Renamed from _get_anki_data anki_items: Set[str] = set() if not query: log.info("get_anki_data: Query is empty, returning no items.") @@ -351,9 +399,11 @@ def get_anki_data(self, query: str, sentence_field_name: str, # Use self.progress_parent if available for the QProgressDialog progress_dialog_parent = self.progress_parent if self.progress_parent else mw progress = QProgressDialog( - f"Scanning Anki notes (field: '{sentence_field_name}')...", - "Cancel", 0, len(note_ids), - progress_dialog_parent # Parent appropriately + f"Scanning Anki notes (field: '{sentence_field_name}')...", + "Cancel", + 0, + len(note_ids), + progress_dialog_parent, # Parent appropriately ) progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setValue(0) @@ -373,9 +423,11 @@ def get_anki_data(self, query: str, sentence_field_name: str, log.warning(f"Could not retrieve note with nid {nid}. Skipping.") continue if sentence_field_name not in note: - log.debug(f"Field '{sentence_field_name}' not in note {nid} (type: {note.note_type()['name'] if note.note_type() else 'Unknown'}). Skipping.") + log.debug( + f"Field '{sentence_field_name}' not in note {nid} (type: {note.note_type()['name'] if note.note_type() else 'Unknown'}). Skipping." + ) continue - + is_mature_enough = False if mature_interval > 0: cards = note.cards() @@ -388,37 +440,47 @@ def get_anki_data(self, query: str, sentence_field_name: str, break if not is_mature_enough: continue - + text_raw = note[sentence_field_name] if not text_raw or not text_raw.strip(): continue text_cleaned = strip_html_media(text_raw) - if use_lemmatization and self.mecab_processor and \ - self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_INITIALIZED: # Stricter check + if ( + use_lemmatization + and self.mecab_processor + and self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_INITIALIZED + ): # Stricter check try: lemmas_from_field = self.mecab_processor.get_lemmas(text_cleaned) anki_items.update(lemmas_from_field) except Exception as e: log.error(f"Error lemmatizing field content from note {nid}: {e}", exc_info=True) else: - temp_val = re.sub(r'\s*[((].*?[))]\s*|\s*\[.*?\]\s*|[\uff08\uff09\u3010\u3011\u300c\u300d\u300e\u300f]', '', text_cleaned).strip() - words_from_field = re.split(r'[\s\uff0c\u3001\uff0e\u3002\uff1f\uff01\uff1b\uff1a?!”;:]+', temp_val) + temp_val = re.sub( + r"\s*[((].*?[))]\s*|\s*\[.*?\]\s*|[\uff08\uff09\u3010\u3011\u300c\u300d\u300e\u300f]", + "", + text_cleaned, + ).strip() + words_from_field = re.split(r"[\s\uff0c\u3001\uff0e\u3002\uff1f\uff01\uff1b\uff1a?!”;:]+", temp_val) anki_items.update(word for word in words_from_field if word) - processed_count +=1 + processed_count += 1 progress.setValue(len(note_ids)) - log.info(f"Processed {processed_count}/{len(note_ids)} notes. Extracted {len(anki_items)} unique items from Anki.") + log.info( + f"Processed {processed_count}/{len(note_ids)} notes. Extracted {len(anki_items)} unique items from Anki." + ) return anki_items - def merge_data(self, existing_csv_data: Dict[str, Set[str]], - anki_items: Set[str]) -> Tuple[Dict[str, Set[str]], Dict[str, int]]: # Renamed from _merge_csv_data + def merge_data( + self, existing_csv_data: Dict[str, Set[str]], anki_items: Set[str] + ) -> Tuple[Dict[str, Set[str]], Dict[str, int]]: # Renamed from _merge_csv_data merged_data = {k: set(v) for k, v in existing_csv_data.items()} stats = { "anki_source_removed_not_in_anki": 0, "word_deleted_no_sources_left": 0, "new_word_from_anki_added": 0, - "anki_source_added_to_existing_word": 0 + "anki_source_added_to_existing_word": 0, } for word in list(merged_data.keys()): @@ -436,13 +498,15 @@ def merge_data(self, existing_csv_data: Dict[str, Set[str]], if item in merged_data: if ANKI_SOURCE_TAG not in merged_data[item]: merged_data[item].add(ANKI_SOURCE_TAG) - stats["anki_source_added_to_existing_word"] +=1 + stats["anki_source_added_to_existing_word"] += 1 else: merged_data[item] = {ANKI_SOURCE_TAG} stats["new_word_from_anki_added"] += 1 return merged_data, stats - def write_csv_data(self, output_path: str, data_to_write: Dict[str, Set[str]]) -> bool: # Renamed from _write_csv_data + def write_csv_data( + self, output_path: str, data_to_write: Dict[str, Set[str]] + ) -> bool: # Renamed from _write_csv_data output_rows: List[List[str]] = [[CSV_COLUMN_WORD, CSV_COLUMN_SOURCE]] for word, sources in sorted(data_to_write.items()): if sources: @@ -453,11 +517,11 @@ def write_csv_data(self, output_path: str, data_to_write: Dict[str, Set[str]]) - return False try: - with open(output_path, 'w', encoding='utf-8', newline='') as f: + with open(output_path, "w", encoding="utf-8", newline="") as f: csv_writer = csv.writer(f) csv_writer.writerows(output_rows) return True - except OSError as e: # Changed IOError to OSError + except OSError as e: # Changed IOError to OSError log.error(f"OSError writing CSV file to '{os.path.basename(output_path)}': {e}", exc_info=True) showInfo(f"Error writing CSV file to '{os.path.basename(output_path)}':\n{e}") return False @@ -480,18 +544,17 @@ def __init__(self, parent=None): self.mecab_processor: Optional[MeCabProcessor] = None if MECAB_AVAILABLE: try: - addon_config = mw.addonManager.getConfig(__name__.split('.')[0]) if mw else {} + addon_config = mw.addonManager.getConfig(__name__.split(".")[0]) if mw else {} _initial_custom_stopwords = addon_config.get(CONFIG_KEY_CUSTOM_STOPWORDS, "") _initial_stopwords_mode = addon_config.get(CONFIG_KEY_CUSTOM_STOPWORDS_MODE, STOPWORDS_MODE_SUPPLEMENT) self.mecab_processor = MeCabProcessor( - custom_stopwords_str=_initial_custom_stopwords, - stopwords_mode=_initial_stopwords_mode + custom_stopwords_str=_initial_custom_stopwords, stopwords_mode=_initial_stopwords_mode ) except Exception as e: log.error(f"Failed to initialize MeCabProcessor for dialog: {e}", exc_info=True) self.mecab_processor = None showInfo("Error initializing MeCab processor. Lemmatization may be unavailable. Check logs.") - + main_layout = QVBoxLayout(self) main_layout.setSpacing(12) @@ -589,7 +652,7 @@ def _setup_csv_dictionary_group(self) -> QGroupBox: self.operation_group.addButton(self.radio_update_existing) self.operation_group.addButton(self.radio_save_as_new) operation_layout = QVBoxLayout() - operation_layout.setContentsMargins(0,0,0,0) + operation_layout.setContentsMargins(0, 0, 0, 0) operation_layout.setSpacing(4) operation_layout.addWidget(self.radio_update_existing) operation_layout.addWidget(self.radio_save_as_new) @@ -601,9 +664,10 @@ def _setup_csv_dictionary_group(self) -> QGroupBox: line_separator.setFrameShadow(QFrame.Shadow.Sunken) form_layout.addRow(line_separator) - self.filter_by_dict_checkbox = QCheckBox("Filter extracted words against a dictionary file") - self.filter_by_dict_checkbox.setToolTip("Only include words/lemmas from Anki if they also exist in the specified dictionary file.") + self.filter_by_dict_checkbox.setToolTip( + "Only include words/lemmas from Anki if they also exist in the specified dictionary file." + ) self.filter_by_dict_checkbox.stateChanged.connect(self.update_dict_filter_state) form_layout.addRow("", self.filter_by_dict_checkbox) @@ -641,7 +705,9 @@ def _setup_lemmatization_group(self) -> QGroupBox: form_layout.addRow(self.lemmatize_checkbox) self.custom_stopwords_edit = QTextEdit() - self.custom_stopwords_edit.setPlaceholderText("Enter custom stopwords (one per line).\nThese supplement or replace built-in stopwords.") + self.custom_stopwords_edit.setPlaceholderText( + "Enter custom stopwords (one per line).\nThese supplement or replace built-in stopwords." + ) self.custom_stopwords_edit.setMinimumHeight(80) form_layout.addRow("Custom Stopwords:", self.custom_stopwords_edit) @@ -708,15 +774,19 @@ def _setup_buttons_layout(self) -> QHBoxLayout: @property def is_lemmatization_module_available(self) -> bool: - return (MECAB_AVAILABLE and - self.mecab_processor is not None and - self.mecab_processor.pos_init_status != MeCabProcessor.STATUS_FAILED) + return ( + MECAB_AVAILABLE + and self.mecab_processor is not None + and self.mecab_processor.pos_init_status != MeCabProcessor.STATUS_FAILED + ) @property def is_lemmatization_fully_functional(self) -> bool: - return (MECAB_AVAILABLE and - self.mecab_processor is not None and - self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_INITIALIZED) + return ( + MECAB_AVAILABLE + and self.mecab_processor is not None + and self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_INITIALIZED + ) def _get_start_directory(self, current_path_text: str) -> str: if current_path_text: @@ -724,8 +794,8 @@ def _get_start_directory(self, current_path_text: str) -> str: if os.path.isfile(current_path_abs): return os.path.dirname(current_path_abs) if os.path.isdir(current_path_abs): - return current_path_abs - if mw and mw.pm and hasattr(mw.pm, 'profileFolder') and mw.pm.profileFolder(): + return current_path_abs + if mw and mw.pm and hasattr(mw.pm, "profileFolder") and mw.pm.profileFolder(): return mw.pm.profileFolder() return os.path.expanduser("~") @@ -755,8 +825,8 @@ def populate_field_combo(self) -> None: all_fields: Set[str] = set() try: for model in mw.col.models.all(): - for field_dict in model['flds']: - all_fields.add(field_dict['name']) + for field_dict in model["flds"]: + all_fields.add(field_dict["name"]) except Exception as e: log.error(f"Error loading fields from Anki models: {e}", exc_info=True) self.field_combo.addItem("Error loading fields") @@ -769,31 +839,30 @@ def populate_field_combo(self) -> None: return sorted_fields = sorted(list(all_fields)) - + current_selection = "" for pf in self.PREFERRED_FIELDS_ORDER: if pf in all_fields: current_selection = pf break - + if not current_selection and self.DEFAULT_FIELD_SUGGESTION in all_fields: current_selection = self.DEFAULT_FIELD_SUGGESTION - + if not current_selection and sorted_fields: current_selection = sorted_fields[0] self.field_combo.clear() self.field_combo.addItems(sorted_fields) - + if current_selection: idx = self.field_combo.findText(current_selection) if idx != -1: self.field_combo.setCurrentIndex(idx) elif self.field_combo.count() > 0: - self.field_combo.setCurrentIndex(0) + self.field_combo.setCurrentIndex(0) elif self.field_combo.count() > 0: - self.field_combo.setCurrentIndex(0) - + self.field_combo.setCurrentIndex(0) def update_operation_mode_state(self) -> None: has_input_path = bool(self.csv_path_edit.text().strip()) @@ -818,7 +887,7 @@ def load_settings(self) -> None: log.warning("Cannot load settings: Anki environment (mw) not available.") return - addon_package = __name__.split('.')[0] + addon_package = __name__.split(".")[0] config = mw.addonManager.getConfig(addon_package) or {} self.csv_path_edit.setText(config.get(CONFIG_KEY_LAST_CSV_PATH, "")) @@ -847,7 +916,7 @@ def load_settings(self) -> None: self.stopwords_replace_radio.setChecked(True) else: self.stopwords_supplement_radio.setChecked(True) - + self._update_mecab_processor_stopwords() self.auto_timestamp_checkbox.setChecked(config.get(CONFIG_KEY_AUTO_TIMESTAMP_FILENAME, False)) @@ -860,19 +929,23 @@ def save_settings(self) -> None: log.warning("Cannot save settings: Anki environment (mw) not available.") return - addon_package = __name__.split('.')[0] + addon_package = __name__.split(".")[0] config = mw.addonManager.getConfig(addon_package) or {} config[CONFIG_KEY_LAST_CSV_PATH] = self.csv_path_edit.text() config[CONFIG_KEY_NOTE_TYPE_FILTER_STRING] = self.note_type_filter_edit.text() config[CONFIG_KEY_LAST_FIELD] = self.field_combo.currentText() config[CONFIG_KEY_LAST_INTERVAL] = self.interval_spinbox.value() - config[CONFIG_KEY_LAST_OPERATION_MODE] = OP_MODE_UPDATE_EXISTING if self.radio_update_existing.isChecked() else OP_MODE_SAVE_AS_NEW + config[CONFIG_KEY_LAST_OPERATION_MODE] = ( + OP_MODE_UPDATE_EXISTING if self.radio_update_existing.isChecked() else OP_MODE_SAVE_AS_NEW + ) if self.is_lemmatization_module_available: config[CONFIG_KEY_LEMMATIZE] = self.lemmatize_checkbox.isChecked() config[CONFIG_KEY_CUSTOM_STOPWORDS] = self.custom_stopwords_edit.toPlainText() - config[CONFIG_KEY_CUSTOM_STOPWORDS_MODE] = STOPWORDS_MODE_REPLACE if self.stopwords_replace_radio.isChecked() else STOPWORDS_MODE_SUPPLEMENT + config[CONFIG_KEY_CUSTOM_STOPWORDS_MODE] = ( + STOPWORDS_MODE_REPLACE if self.stopwords_replace_radio.isChecked() else STOPWORDS_MODE_SUPPLEMENT + ) else: config.pop(CONFIG_KEY_LEMMATIZE, None) config.pop(CONFIG_KEY_CUSTOM_STOPWORDS, None) @@ -893,7 +966,7 @@ def select_existing_csv(self) -> None: path, _ = QFileDialog.getOpenFileName(self, "Select CSV File", start_dir, "CSV Files (*.csv);;All Files (*)") if path: self.csv_path_edit.setText(path) - + # Method _determine_output_path remains in ExportVocabCsvDialog as it directly uses UI elements # for QFileDialog and radio button states. def _determine_output_path(self) -> Optional[str]: @@ -916,13 +989,13 @@ def _determine_output_path(self) -> Optional[str]: suggested_filename_base = os.path.basename(input_csv_path) elif os.path.isdir(input_csv_path): start_dir = input_csv_path - + initial_file_path_suggestion = os.path.join(start_dir, suggested_filename_base) filter_string_for_dialog = "CSV Files (*.csv);;All Files (*)" raw_path, _ = QFileDialog.getSaveFileName( self, "Save Known Words CSV", initial_file_path_suggestion, filter_string_for_dialog - ) # QFileDialog needs a parent, `self` (the dialog) is appropriate - + ) # QFileDialog needs a parent, `self` (the dialog) is appropriate + if not raw_path: return None @@ -936,7 +1009,7 @@ def _determine_output_path(self) -> Optional[str]: output_path = f"{name_no_ext}_{timestamp}{current_ext}" else: output_path = raw_path - + if output_path and os.path.splitext(output_path)[1].lower() != ".csv": output_path = os.path.splitext(output_path)[0] + ".csv" log.warning(f"Output path extension corrected to .csv: {output_path}") @@ -955,7 +1028,7 @@ def on_process_button_clicked(self) -> None: if not note_type_filter_text: showInfo("Please enter a filter string for note type names (e.g., 'japanese'). Cannot be empty.") return - + normalized_filter = note_type_filter_text.lower() try: all_note_type_infos = mw.col.models.all_names_and_ids() @@ -972,12 +1045,14 @@ def on_process_button_clicked(self) -> None: for nt_info in all_note_type_infos: if normalized_filter in nt_info.name.lower(): matching_model_names.append(nt_info.name) - + if not matching_model_names: showInfo(f"No note types found with names containing: '{note_type_filter_text}'.") return - note_type_queries = [f'note:"{name.replace(_SINGLE_DBL_QUOTE, _ESCAPED_DBL_QUOTE)}"' for name in matching_model_names] + note_type_queries = [ + f'note:"{name.replace(_SINGLE_DBL_QUOTE, _ESCAPED_DBL_QUOTE)}"' for name in matching_model_names + ] anki_query = f"({' OR '.join(note_type_queries)})" log.info(f"Constructed Anki query: {anki_query}") @@ -985,14 +1060,17 @@ def on_process_button_clicked(self) -> None: selected_field_name = self.field_combo.currentText() mature_interval = self.interval_spinbox.value() # Check for lemmatization fully functional (stricter) before enabling - use_lemmatization = (self.is_lemmatization_fully_functional and - self.lemmatize_checkbox.isChecked()) - + use_lemmatization = self.is_lemmatization_fully_functional and self.lemmatize_checkbox.isChecked() + use_dictionary_filter = self.filter_by_dict_checkbox.isChecked() dictionary_path = self.dict_path_edit.text().strip() dictionary_set: Set[str] = set() - if not selected_field_name or selected_field_name in ["No fields found", "Error loading fields", "Error: Anki collection not available"]: + if not selected_field_name or selected_field_name in [ + "No fields found", + "Error loading fields", + "Error: Anki collection not available", + ]: showInfo("Please select a valid Anki Field to process.") return @@ -1003,27 +1081,30 @@ def on_process_button_clicked(self) -> None: if not os.path.exists(dictionary_path): showInfo(f"Dictionary file not found at: {dictionary_path}") return - dictionary_set = _load_dictionary_file(dictionary_path) # _load_dictionary_file is a module-level function + dictionary_set = _load_dictionary_file(dictionary_path) # _load_dictionary_file is a module-level function if not dictionary_set and os.path.exists(dictionary_path): - showInfo(f"The dictionary file '{os.path.basename(dictionary_path)}' was found but is empty or failed to load. " - "Filtering against an empty dictionary will result in no words from Anki being included.") + showInfo( + f"The dictionary file '{os.path.basename(dictionary_path)}' was found but is empty or failed to load. " + "Filtering against an empty dictionary will result in no words from Anki being included." + ) - if use_lemmatization and self.mecab_processor: # mecab_processor must exist for use_lemmatization to be true - self._update_mecab_processor_stopwords() # Dialog method updates its mecab_processor instance + if use_lemmatization and self.mecab_processor: # mecab_processor must exist for use_lemmatization to be true + self._update_mecab_processor_stopwords() # Dialog method updates its mecab_processor instance if not self.mecab_processor.test_mecab_and_pos(): - tooltip("Warning: MeCab self-test failed. Lemmatization results may be inaccurate. Check add-on logs for details.", period=7000, parent=self) + tooltip( + "Warning: MeCab self-test failed. Lemmatization results may be inaccurate. Check add-on logs for details.", + period=7000, + parent=self, + ) existing_csv_data: Dict[str, Set[str]] = {} if input_csv_path: if self.radio_update_existing.isChecked() and not os.path.exists(input_csv_path): showInfo(f"Update operation selected, but the CSV file does not exist: {input_csv_path}") return - existing_csv_data = processor.read_csv_data(input_csv_path) # Use processor instance + existing_csv_data = processor.read_csv_data(input_csv_path) # Use processor instance - anki_items_raw = processor.get_anki_data( - anki_query, selected_field_name, - mature_interval, use_lemmatization - ) + anki_items_raw = processor.get_anki_data(anki_query, selected_field_name, mature_interval, use_lemmatization) anki_items_final: Set[str] filtered_out_by_dict_count = 0 @@ -1032,41 +1113,45 @@ def on_process_button_clicked(self) -> None: anki_items_final = {item for item in anki_items_raw if item in dictionary_set} filtered_out_by_dict_count = original_count - len(anki_items_final) if not anki_items_final and original_count > 0: - showInfo(f"No Anki items matched the words in the provided dictionary. " - f"{original_count} items were initially extracted from Anki; all {filtered_out_by_dict_count} were filtered out by the dictionary.") + showInfo( + f"No Anki items matched the words in the provided dictionary. " + f"{original_count} items were initially extracted from Anki; all {filtered_out_by_dict_count} were filtered out by the dictionary." + ) else: anki_items_final = anki_items_raw if not anki_items_final and not existing_csv_data: message = "No Anki items to process (none found matching criteria" if use_dictionary_filter and anki_items_raw: - message += ", or all filtered out by dictionary" + message += ", or all filtered out by dictionary" message += ") and no existing CSV data to merge/update. Nothing to save." showInfo(message) return - merged_data, merge_stats = processor.merge_data(existing_csv_data, anki_items_final) # Use processor instance + merged_data, merge_stats = processor.merge_data(existing_csv_data, anki_items_final) # Use processor instance - output_path = self._determine_output_path() # This dialog method remains as it needs UI state + output_path = self._determine_output_path() # This dialog method remains as it needs UI state if not output_path: tooltip("Save operation cancelled or output path could not be determined.", parent=self) return - if processor.write_csv_data(output_path, merged_data): # Use processor instance + if processor.write_csv_data(output_path, merged_data): # Use processor instance num_words_saved = len(merged_data) stats_summary_parts = [ f"New from Anki: {merge_stats['new_word_from_anki_added']}", f"Anki source added: {merge_stats['anki_source_added_to_existing_word']}", f"Anki source removed (no longer in Anki selection): {merge_stats['anki_source_removed_not_in_anki']}", - f"Words deleted (no sources left): {merge_stats['word_deleted_no_sources_left']}" + f"Words deleted (no sources left): {merge_stats['word_deleted_no_sources_left']}", ] if use_dictionary_filter: stats_summary_parts.append(f"Filtered out by dictionary: {filtered_out_by_dict_count}") stats_summary = ". ".join(stats_summary_parts) + "." - - parent_widget_for_final_info = self if self.isVisible() else mw - showInfo(f"Successfully saved {num_words_saved} words to:\n{os.path.basename(output_path)}\n\nSummary:\n{stats_summary}", - parent=parent_widget_for_final_info) + + parent_widget_for_final_info = self if self.isVisible() else mw + showInfo( + f"Successfully saved {num_words_saved} words to:\n{os.path.basename(output_path)}\n\nSummary:\n{stats_summary}", + parent=parent_widget_for_final_info, + ) if output_path.lower() != self.csv_path_edit.text().strip().lower(): self.csv_path_edit.setText(output_path) @@ -1074,7 +1159,7 @@ def on_process_button_clicked(self) -> None: self.save_settings() self.accept() - + def on_mecab_test_run_clicked(self) -> None: test_text = self.mecab_test_input_edit.text().strip() self.mecab_test_output_display.clear() @@ -1087,8 +1172,10 @@ def on_mecab_test_run_clicked(self) -> None: if not self.is_lemmatization_fully_functional: msg = "MeCab is not fully functional for testing. " - if not MECAB_AVAILABLE: msg += "MeCab components are not installed. " - elif self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_FAILED: msg += "Part-of-Speech initialization failed. " + if not MECAB_AVAILABLE: + msg += "MeCab components are not installed. " + elif self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_FAILED: + msg += "Part-of-Speech initialization failed. " elif self.mecab_processor.pos_init_status == MeCabProcessor.STATUS_UNINITIALIZED: msg += "Part-of-Speech uninitialized. Attempting re-initialization..." log.info("MeCab test: POS uninitialized, attempting re-init.") @@ -1099,7 +1186,7 @@ def on_mecab_test_run_clicked(self) -> None: msg += "\nRe-initialization failed. Check logs for details." else: msg += "Current status: " + self.mecab_processor.pos_init_status + ". Check logs." - + self.mecab_test_output_display.setText(msg) log.warning(f"Mecab test: Not fully functional. Status: {self.mecab_processor.pos_init_status}") return @@ -1114,12 +1201,15 @@ def on_mecab_test_run_clicked(self) -> None: if lemmas: self.mecab_test_output_display.setText("\n".join(sorted(list(lemmas)))) else: - self.mecab_test_output_display.setText("No lemmas extracted. Input might be empty after processing, " - "all tokens might be stopwords, or filtered by Part-of-Speech rules.") + self.mecab_test_output_display.setText( + "No lemmas extracted. Input might be empty after processing, " + "all tokens might be stopwords, or filtered by Part-of-Speech rules." + ) except Exception as e: log.error(f"An error occurred during MeCab lemmatization test: {e}", exc_info=True) self.mecab_test_output_display.setText(f"An error occurred during lemmatization test:\n{e}") + def show_export_vocab_csv_dialog(): if not mw: log.error("Cannot show dialog: Anki environment (mw) not available.") From b33e5164574b82a69ac6f9ea78059a11abab0759 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Fri, 9 May 2025 20:02:31 +0300 Subject: [PATCH 06/10] extract function --- japanese/gui.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/japanese/gui.py b/japanese/gui.py index 8f6eb136..164a47e7 100644 --- a/japanese/gui.py +++ b/japanese/gui.py @@ -684,14 +684,16 @@ def add_deck_download_action(root_menu: QMenu): root_menu.addAction(menu_action) -def init(): - root_menu = menu_root_entry() - +def add_export_known_vocab_action(root_menu: QMenu) -> None: export_action = QAction("Export Known Vocab", root_menu) qconnect(export_action.triggered, show_export_vocab_csv_dialog) root_menu.addAction(export_action) + +def init(): + root_menu = menu_root_entry() add_settings_action(root_menu) add_deck_download_action(root_menu) + add_export_known_vocab_action(root_menu) set_config_action(lambda: SettingsDialog(mw)) set_config_update_action(cfg.update_from_addon_manager) From 401bf423f8550d2832fdff6fbd38e72ee90200dd Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Fri, 9 May 2025 20:04:12 +0300 Subject: [PATCH 07/10] add return type --- japanese/gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/japanese/gui.py b/japanese/gui.py index 164a47e7..9b746ba4 100644 --- a/japanese/gui.py +++ b/japanese/gui.py @@ -62,7 +62,7 @@ ADDON_SETUP_GUIDE = "https://tatsumoto-ren.github.io/blog/anki-japanese-support.html" -def show_export_vocab_csv_dialog(): +def show_export_vocab_csv_dialog() -> None: if not mw: # pragma: no cover return dialog = ExportVocabCsvDialog(mw) From 4a40858437906ad36504d09e059f2ca0487aaf87 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Fri, 9 May 2025 20:10:31 +0300 Subject: [PATCH 08/10] add copyright --- japanese/known_words/__init__.py | 2 ++ japanese/known_words/main.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/japanese/known_words/__init__.py b/japanese/known_words/__init__.py index 8b137891..1c0284e3 100644 --- a/japanese/known_words/__init__.py +++ b/japanese/known_words/__init__.py @@ -1 +1,3 @@ +# Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html diff --git a/japanese/known_words/main.py b/japanese/known_words/main.py index ef3a8c46..2308bc91 100644 --- a/japanese/known_words/main.py +++ b/japanese/known_words/main.py @@ -1,3 +1,5 @@ +# Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import csv import datetime import logging From dd1a05e6d701a17cb2fa07bbd3ae3d1d18890149 Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Fri, 9 May 2025 20:42:37 +0300 Subject: [PATCH 09/10] annotate collection --- japanese/known_words/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/japanese/known_words/main.py b/japanese/known_words/main.py index 2308bc91..31e3b482 100644 --- a/japanese/known_words/main.py +++ b/japanese/known_words/main.py @@ -7,6 +7,7 @@ import re from typing import Any, Dict, List, Optional, Set, Tuple +from anki.collection import Collection from anki.utils import strip_html_media from aqt import mw from aqt.qt import ( @@ -331,7 +332,7 @@ def update_stopwords(self, custom_stopwords_str: str, mode: str) -> None: class KnownWordsProcessor: - def __init__(self, col: Any, mecab_processor: Optional[MeCabProcessor], progress_parent: Optional[QWidget] = None): + def __init__(self, col: Collection, mecab_processor: Optional[MeCabProcessor], progress_parent: Optional[QWidget] = None): self.col = col self.mecab_processor = mecab_processor self.progress_parent = progress_parent From be0d125cca92f11e913ea34a11e12a9b626c751a Mon Sep 17 00:00:00 2001 From: Ren Tatsumoto Date: Fri, 9 May 2025 20:42:49 +0300 Subject: [PATCH 10/10] move init --- japanese/known_words/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/japanese/known_words/main.py b/japanese/known_words/main.py index 31e3b482..c1cec1dd 100644 --- a/japanese/known_words/main.py +++ b/japanese/known_words/main.py @@ -1024,9 +1024,6 @@ def on_process_button_clicked(self) -> None: showInfo("Critical error: Anki collection (mw.col) is not available. Cannot proceed.") return - # Instantiate the processor, passing `self` as progress_parent - processor = KnownWordsProcessor(mw.col, self.mecab_processor, progress_parent=self) - note_type_filter_text = self.note_type_filter_edit.text().strip() if not note_type_filter_text: showInfo("Please enter a filter string for note type names (e.g., 'japanese'). Cannot be empty.") @@ -1100,6 +1097,9 @@ def on_process_button_clicked(self) -> None: parent=self, ) + # Instantiate the processor, passing `self` as progress_parent + processor = KnownWordsProcessor(mw.col, self.mecab_processor, progress_parent=self) + existing_csv_data: Dict[str, Set[str]] = {} if input_csv_path: if self.radio_update_existing.isChecked() and not os.path.exists(input_csv_path):