diff --git a/language-practice b/language-practice index 60f4ebb..19cef3a 100755 --- a/language-practice +++ b/language-practice @@ -11,9 +11,7 @@ Inflection charts are pulled from wiktionary. import argparse import sys -from language_practice.gui import GuiApplication from language_practice.sqlite import SqliteHandle -from language_practice.terminal import TerminalApplication class Once(argparse.Action): @@ -53,9 +51,11 @@ def main(): try: all_sets = handle.get_all_sets() if args.gui: + from language_practice.gui import GuiApplication # pylint: disable=import-outside-toplevel gui = GuiApplication(handle, all_sets) gui.run() else: + from language_practice.terminal import TerminalApplication # pylint: disable=import-outside-toplevel tui = TerminalApplication(handle, all_sets) tui.run() except Exception as err: # pylint: disable=broad-exception-caught diff --git a/language_practice/config.py b/language_practice/config.py index c4a3523..eecce77 100644 --- a/language_practice/config.py +++ b/language_practice/config.py @@ -24,7 +24,7 @@ def __init__( aspect: str | None, usage: str | None, part_of_speech: str | None, - charts: list[list[list[str]]] | None, + charts: list[list[str]] | None, repetition: WordRepetition, ): self.word = word @@ -72,7 +72,7 @@ def get_part_of_speech(self) -> str | None: """ return self.part_of_speech - def get_charts(self) -> list[list[list[str]]] | None: + def get_charts(self) -> list[list[str]] | None: """ Get charts. """ diff --git a/language_practice/gui.py b/language_practice/gui.py index d201093..046fdee 100644 --- a/language_practice/gui.py +++ b/language_practice/gui.py @@ -6,6 +6,7 @@ # pylint: disable=too-few-public-methods import asyncio +from sqlite3 import IntegrityError import tomllib from typing import Self @@ -62,10 +63,8 @@ def __init__( self.flashcard_set_grid = FlashcardSetGrid() for flashcard_set in flashcard_sets: - delete_button = Gtk.Button(label="Delete") - delete_button.connect("clicked", self.delete_flashcard_set) self.flashcard_set_grid.add_row( - Gtk.CheckButton(), Gtk.Label.new(flashcard_set), delete_button + Gtk.CheckButton(), Gtk.Label.new(flashcard_set) ) scrollable = Gtk.ScrolledWindow() scrollable.set_size_request(700, 600) @@ -75,6 +74,9 @@ def __init__( import_button = Gtk.Button(label="Import") import_button.connect("clicked", self.import_button) button_hbox.append(import_button) + delete_button = Gtk.Button(label="Delete") + delete_button.connect("clicked", self.delete_flashcard_set) + button_hbox.append(delete_button) select_all_button = Gtk.Button(label="Select all") select_all_button.connect("clicked", self.flashcard_set_grid.select_all) button_hbox.append(select_all_button) @@ -96,12 +98,18 @@ def import_button(self, button: Gtk.Button): file_dialog = Gtk.FileDialog() file_dialog.open_multiple(callback=self.handle_files) + # pylint: disable=unused-argument def delete_flashcard_set(self, button: Gtk.Button): """ Handle deleting flashcard set on button press. """ - self.handle.delete_set(button.get_prev_sibling().get_text()) - self.flashcard_set_grid.delete_row(button) + selected = self.flashcard_set_grid.get_selected() + selected.sort(reverse=True, key=lambda info: info[1]) + for (text, row) in selected: + set_id = self.handle.get_id_from_file_name(text) + if set_id is not None: + self.handle.delete_set(set_id) + self.flashcard_set_grid.delete_row(row) def handle_files(self, dialog: Gtk.FileDialog, task: Gio.Task): """ @@ -111,19 +119,31 @@ def handle_files(self, dialog: Gtk.FileDialog, task: Gio.Task): for current_import in self.imports: try: toml = TomlConfig(current_import) - except tomllib.TOMLDecodeError: + except tomllib.TOMLDecodeError as err: + dialog = Gtk.AlertDialog() + dialog.set_message(f"{current_import}: {err}") + dialog.set_modal(True) + dialog.choose() continue - self.handle.import_set( - current_import, - toml, - asyncio.run(scrape(toml.get_words(), toml.get_lang())), - ) + try: + self.handle.import_set( + current_import, + toml, + asyncio.run(scrape(toml.get_words(), toml.get_lang())), + ) + except IntegrityError as err: + dialog = Gtk.AlertDialog() + dialog.set_message(f"{current_import}: {err}") + dialog.set_modal(True) + dialog.choose() + set_id = self.handle.get_id_from_file_name(current_import) + if set_id is not None: + self.handle.delete_set(set_id) + continue - delete_button = Gtk.Button(label="Delete") - delete_button.connect("clicked", self.delete_flashcard_set) self.flashcard_set_grid.add_row( - Gtk.CheckButton(), Gtk.Label.new(current_import), delete_button + Gtk.CheckButton(), Gtk.Label.new(current_import), ) self.imports = [] @@ -134,11 +154,11 @@ def handle_start(self, button: Gtk.Button): """ files = self.flashcard_set_grid.get_selected() config = None - for file in files: + for (text, _) in files: if config is None: - config = self.handle.load_config(file) + config = self.handle.load_config(text) else: - config = config.extend(self.handle.load_config(file)) + config = config.extend(self.handle.load_config(text)) if config is not None: self.flashcard = Flashcard(self.handle, config.get_words()) @@ -168,22 +188,21 @@ def __init__(self, *args, **kwargs): self.num_rows = 0 def add_row( - self, checkbox: Gtk.CheckButton, label: Gtk.Label, delete_button: Gtk.Button + self, checkbox: Gtk.CheckButton, label: Gtk.Label ): """ Add a row to the grid. """ self.attach(checkbox, 0, self.num_rows, 1, 1) self.attach(label, 1, self.num_rows, 1, 1) - self.attach(delete_button, 2, self.num_rows, 1, 1) self.num_rows += 1 - def delete_row(self, contains_child: Gtk.Button): + def delete_row(self, row): """ Delete a row from the grid. """ - info = self.query_child(contains_child) - self.remove_row(info.row) + self.remove_row(row) + self.num_rows -= 1 # pylint: disable=unused-argument def select_all(self, button: Gtk.Button): @@ -194,14 +213,14 @@ def select_all(self, button: Gtk.Button): self.get_child_at(0, row).set_active(True) # pylint: disable=unused-argument - def get_selected(self) -> list[str]: + def get_selected(self) -> list[tuple[str, int]]: """ Get all selected flashcard sets. """ files = [] for row in range(self.num_rows): if self.get_child_at(0, row).get_active(): - files.append(self.get_child_at(1, row).get_text()) + files.append((self.get_child_at(1, row).get_text(), row)) return files diff --git a/language_practice/sqlite.py b/language_practice/sqlite.py index 3970c22..237ab2d 100644 --- a/language_practice/sqlite.py +++ b/language_practice/sqlite.py @@ -13,18 +13,25 @@ class SqliteHandle: Handler for sqlite operations. """ - FLASHCARDS_SCHEMA = "file_name TEXT PRIMARY KEY, lang TEXT" + FLASHCARDS_TABLE_NAME = "flashcard_sets" + FLASHCARDS_SCHEMA = ( + "id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT, lang TEXT" + ) + WORD_TABLE_NAME = "words" WORD_SCHEMA = ( - "word TEXT PRIMARY KEY, definition TEXT, gender TEXT, aspect TEXT, " - "usage TEXT, part_of_speech TEXT, easiness_factor REAL, num_correct INTEGER, " - "in_n_days INTEGER, date_of_next TEXT, review NUMERIC, file_name TEXT" + "word TEXT PRIMARY KEY NOT NULL, definition TEXT NOT NULL, gender TEXT, " + "aspect TEXT, usage TEXT, part_of_speech TEXT, easiness_factor REAL, " + "num_correct INTEGER, in_n_days INTEGER, date_of_next TEXT, review NUMERIC, " + "flashcard_set_id INTEGER" ) def __init__(self, db: str): self.conn = sqlite3.connect(db) self.cursor = self.conn.cursor() - self.create_table_idempotent("flashcard_sets", SqliteHandle.FLASHCARDS_SCHEMA) + self.create_table_idempotent( + SqliteHandle.FLASHCARDS_TABLE_NAME, SqliteHandle.FLASHCARDS_SCHEMA + ) def create_table_idempotent(self, name: str, schema: str): """ @@ -57,17 +64,210 @@ def insert_into(self, name: str, columns: str, values: str): """ Insert into table. """ - self.cursor.execute(f"INSERT INTO '{name}' ({columns}) VALUES({values});") + self.cursor.execute( + f"INSERT OR IGNORE INTO '{name}' ({columns}) VALUES({values});" + ) + + def update(self, name: str, set_statements: str, condition: str): + """ + Insert into table. + """ + self.cursor.execute(f"UPDATE '{name}' SET {set_statements} WHERE {condition};") + + def get_id_from_file_name(self, file_name: str) -> int | None: + """ + Checks whether the flashcard set already exists. + """ + table_name = SqliteHandle.FLASHCARDS_TABLE_NAME + res = self.cursor.execute( + f"SELECT id FROM {table_name} WHERE file_name = '{file_name}';" + ) + set_id = res.fetchone() + if set_id is not None: + set_id = set_id[0] + return set_id - # pylint: disable=too-many-nested-blocks - # pylint: disable=too-many-statements - # pylint: disable=too-many-branches # pylint: disable=too-many-locals - def import_set( + def update_set( + self, set_id: int, config: Config, scraped: dict[str, list[list[list[str]]]] + ): + """ + Update an existing flashcard set. + """ + table_name = SqliteHandle.WORD_TABLE_NAME + res = self.cursor.execute( + f"SELECT word FROM {table_name} WHERE flashcard_set_id = {set_id}" + ) + current_words = set(map(lambda word: word[0], res.fetchall())) + config_word_dct = {entry.get_word(): entry for entry in config} + config_words = set(config_word_dct.keys()) + + words_to_add = config_words - current_words + for word in words_to_add: + self.insert_word(set_id, config_word_dct[word], scraped.get(word, None)) + + words_to_update = current_words & config_words + for word in words_to_update: + self.update_word(config_word_dct[word], scraped.get(word, None)) + + words_to_delete = current_words - config_words + for word in words_to_delete: + self.delete(SqliteHandle.WORD_TABLE_NAME, "word = '{word}'") + res = self.cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table';" + ) + names = res.fetchall() + names_to_drop = [name[0] for name in names if name[0].startswith(word)] + for name in names_to_drop: + self.drop_table(name) + + def insert_word( + self, set_id: int, entry: Entry, scraped: list[list[list[str]]] | None + ): + """ + Insert a new word into the table. + """ + word = entry.get_word() + definition = entry.get_definition() + gender = entry.get_gender() + aspect = entry.get_aspect() + usage = entry.get_usage() + part_of_speech = entry.get_part_of_speech() + charts = entry.get_charts() + repetition = entry.get_repetition() + easiness_factor = repetition.get_easiness_factor() + num_correct = repetition.get_num_correct() + in_n_days = repetition.get_in_n_days() + date_of_next = repetition.get_date_of_next() + review = 1 if repetition.get_review() else 0 + if charts is None: + final_charts = scraped + else: + final_charts = [charts] + + if final_charts is not None: + for i, chart in enumerate(final_charts): + max_len = max(map(len, chart)) + schema = ", ".join([f"{chr(i + 97)} TEXT" for i in range(0, max_len)]) + self.recreate_table(f"{word}-{i}", schema) + for row in chart: + columns = [] + values = [] + for j in range(0, max_len): + try: + val = row[j] + val = val.replace("'", "''") + except IndexError: + pass + else: + columns.append(chr(j + 97)) + values.append(f"'{val}'") + self.insert_into( + f"{word}-{i}", ", ".join(columns), ", ".join(values) + ) + + word = word.replace("'", "''") + + columns = [ + "word", + "definition", + "easiness_factor", + "num_correct", + "in_n_days", + "date_of_next", + "review", + "flashcard_set_id", + ] + values = [ + f"'{word}'", + f"'{definition}'", + f"{easiness_factor}", + f"{num_correct}", + f"{in_n_days}", + f"'{date_of_next}'", + f"{review}", + f"'{set_id}'", + ] + if gender is not None: + columns.append("gender") + values.append(f"'{gender}'") + if aspect is not None: + columns.append("aspect") + values.append(f"'{aspect}'") + if usage is not None: + columns.append("usage") + values.append(f"'{usage}'") + if part_of_speech is not None: + columns.append("part_of_speech") + values.append(f"'{part_of_speech}'") + self.insert_into("words", ", ".join(columns), ", ".join(values)) + + # pylint: disable=too-many-branches + def update_word(self, entry: Entry, scraped: list[list[list[str]]] | None): + """ + Update existing word in the table. + """ + word = entry.get_word() + definition = entry.get_definition() + gender = entry.get_gender() + aspect = entry.get_aspect() + usage = entry.get_usage() + part_of_speech = entry.get_part_of_speech() + charts = entry.get_charts() + if charts is None: + final_charts = scraped + else: + final_charts = [charts] + + res = self.cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + names = res.fetchall() + names_to_drop = [name[0] for name in names if name[0].startswith(word)] + for name in names_to_drop: + self.drop_table(name) + + if final_charts is not None: + for i, chart in enumerate(final_charts): + max_len = max(map(len, chart)) + schema = ", ".join([f"{chr(i + 97)} TEXT" for i in range(0, max_len)]) + self.recreate_table(f"{word}-{i}", schema) + for row in chart: + columns = [] + values = [] + for j in range(0, max_len): + try: + val = row[j] + val = val.replace("'", "''") + except IndexError: + pass + else: + columns.append(chr(j + 97)) + values.append(f"'{val}'") + self.insert_into( + f"{word}-{i}", ", ".join(columns), ", ".join(values) + ) + + set_statements = [ + f"word = '{word}'", + f"definition = '{definition}'", + ] + if gender is not None: + set_statements.append(f"gender = '{gender}'") + if aspect is not None: + set_statements.append(f"aspect = '{aspect}'") + if usage is not None: + set_statements.append(f"usage = '{usage}'") + if part_of_speech is not None: + set_statements.append(f"part_of_speech = '{part_of_speech}'") + + self.update( + SqliteHandle.WORD_TABLE_NAME, ", ".join(set_statements), f"word = {word}" + ) + + def create_new_set( self, file_name: str, config: Config, scraped: dict[str, list[list[list[str]]]] ): """ - Import set into database. + Create a new flashcard set. """ lang = config.get_lang() columns = ["file_name"] @@ -76,92 +276,37 @@ def import_set( columns.append("lang") values.append(f"'{lang}'") self.insert_into("flashcard_sets", ", ".join(columns), ", ".join(values)) + set_id = self.cursor.lastrowid self.create_table_idempotent( - "words", + SqliteHandle.WORD_TABLE_NAME, SqliteHandle.WORD_SCHEMA, ) - for entry in iter(config): - word = entry.get_word() - definition = entry.get_definition() - gender = entry.get_gender() - aspect = entry.get_aspect() - usage = entry.get_usage() - part_of_speech = entry.get_part_of_speech() - charts = entry.get_charts() - repetition = entry.get_repetition() - easiness_factor = repetition.get_easiness_factor() - num_correct = repetition.get_num_correct() - in_n_days = repetition.get_in_n_days() - date_of_next = repetition.get_date_of_next() - review = 1 if repetition.get_review() else 0 - if charts is None: - charts = scraped.get(entry.get_word(), None) - else: - charts = [charts] - - if charts is not None: - for i, chart in enumerate(charts): - max_len = max(map(len, chart)) - schema = ", ".join( - [f"{chr(i + 97)} TEXT" for i in range(0, max_len)] - ) - self.recreate_table(f"{word}-{i}", schema) - for row in chart: - columns = [] - values = [] - for j in range(0, max_len): - try: - val = row[j] - val = val.replace("'", "''") - except IndexError: - pass - else: - columns.append(chr(j + 97)) - values.append(f"'{val}'") - self.insert_into( - f"{word}-{i}", ", ".join(columns), ", ".join(values) - ) - - columns = [ - "word", - "definition", - "easiness_factor", - "num_correct", - "in_n_days", - "date_of_next", - "review", - "file_name", - ] - values = [ - f"'{word}'", - f"'{definition}'", - f"{easiness_factor}", - f"{num_correct}", - f"{in_n_days}", - f"'{date_of_next}'", - f"{review}", - f"'{file_name}'", - ] - if gender is not None: - columns.append("gender") - values.append(f"'{gender}'") - if aspect is not None: - columns.append("aspect") - values.append(f"'{aspect}'") - if usage is not None: - columns.append("usage") - values.append(f"'{usage}'") - if part_of_speech is not None: - columns.append("part_of_speech") - values.append(f"'{part_of_speech}'") - self.insert_into("words", ", ".join(columns), ", ".join(values)) - - def delete_set(self, file_name: str): + if set_id is not None: + for entry in iter(config): + self.insert_word(set_id, entry, scraped.get(entry.get_word(), None)) + + # pylint: disable=too-many-nested-blocks + # pylint: disable=too-many-statements + # pylint: disable=too-many-branches + # pylint: disable=too-many-locals + def import_set( + self, file_name: str, config: Config, scraped: dict[str, list[list[list[str]]]] + ): + """ + Import set into database. + """ + set_id = self.get_id_from_file_name(file_name) + if set_id is None: + self.create_new_set(file_name, config, scraped) + else: + self.update_set(set_id, config, scraped) + + def delete_set(self, set_id: int): """ Delete a set from the database. """ words = self.cursor.execute( - f"SELECT word FROM 'words' WHERE file_name = '{file_name}';" + f"SELECT word FROM 'words' WHERE flashcard_set_id = {set_id};" ).fetchall() res = self.cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") names = res.fetchall() @@ -170,14 +315,14 @@ def delete_set(self, file_name: str): ] for name in names_to_drop: self.drop_table(name) - self.drop_table(file_name) - self.delete("words", f"file_name = '{file_name}'") - self.delete("flashcard_sets", f"file_name = '{file_name}'") + self.delete("words", f"flashcard_set_id = {set_id}") + self.delete("flashcard_sets", f"id = {set_id}") def load_config(self, file_name: str) -> Config: """ Load config from database. """ + set_id = self.get_id_from_file_name(file_name) res = self.cursor.execute( f"SELECT lang FROM flashcard_sets WHERE file_name = '{file_name}';" ) @@ -186,7 +331,7 @@ def load_config(self, file_name: str) -> Config: res = self.cursor.execute( f"SELECT word, definition, gender, aspect, usage, part_of_speech, " f"easiness_factor, num_correct, in_n_days, date_of_next, review " - f"FROM 'words' WHERE file_name = '{file_name}';" + f"FROM 'words' WHERE flashcard_set_id = '{set_id}';" ) entries = res.fetchall() @@ -249,17 +394,19 @@ def update_config(self, word: str, repetition: WordRepetition): in_n_days = repetition.get_in_n_days() date_of_next = str(repetition.get_date_of_next()) review = 1 if repetition.get_review() else 0 - self.cursor.execute( - f"UPDATE words SET easiness_factor = {easiness_factor}, num_correct = " - f"{num_correct}, in_n_days = {in_n_days}, date_of_next = '{date_of_next}', " - f"review = {review} WHERE word = '{word}';" + self.update( + SqliteHandle.WORD_TABLE_NAME, + f"easiness_factor = {easiness_factor}, num_correct = {num_correct}, " + f"in_n_days = {in_n_days}, date_of_next = '{date_of_next}', " + f"review = {review}", + f"word = '{word}'", ) def get_all_sets(self) -> list[str]: """ Get all flashcard sets from database. """ - res = self.cursor.execute("SELECT * FROM flashcard_sets;") + res = self.cursor.execute("SELECT file_name FROM flashcard_sets;") return [entry[0] for entry in res.fetchall()] def close(self): diff --git a/language_practice/terminal.py b/language_practice/terminal.py index 6290b89..1cf638a 100644 --- a/language_practice/terminal.py +++ b/language_practice/terminal.py @@ -3,6 +3,7 @@ """ import os +from sqlite3 import IntegrityError import tomllib from uuid import uuid4 @@ -71,10 +72,7 @@ def compose(self): args.append( Horizontal( Checkbox(id=f"select-{hex_string}"), - Container(), Label(flashcard_set), - Container(), - Button("Delete", id=f"delete-{hex_string}"), classes="flashcard-set", ) ) @@ -82,6 +80,7 @@ def compose(self): yield Horizontal( Container(), Button("Import", id="import"), + Button("Delete", id="delete"), Button("Select all", id="select_all"), Button("Start", id="start"), Button("Exit", id="exit"), @@ -111,14 +110,23 @@ def on_start(self): self.flashcard = Flashcard(self.handle, config.get_words()) self.push_screen(StudyScreen(self.flashcard)) - def on_delete(self, button_id: str, parent: DOMNode | None): + def on_delete(self): """ On delete button press. """ - name = bytes.fromhex(button_id.split("delete-")[1]).decode("utf-8") - self.handle.delete_set(name) - if parent is not None: - parent.remove() # type: ignore + scrollable = self.query_one("#scrollable") + checkboxes = map(lambda child: child.query_one(Checkbox), scrollable.children) + for checkbox in checkboxes: + if checkbox.value: + checkbox_id = checkbox.id + if checkbox_id is not None: + name = bytes.fromhex(checkbox_id.split("select-")[1]).decode( + "utf-8" + ) + set_id = self.handle.get_id_from_file_name(name) + if set_id is not None: + self.handle.delete_set(set_id) + checkbox.parent.remove() def on_exit_study(self): """ @@ -133,19 +141,28 @@ async def on_complete_import(self): """ On complete import button press. """ + await self.pop_screen() toml = None for import_file in self.imports: try: toml = TomlConfig(import_file) - except tomllib.TOMLDecodeError: - self.pop_screen() + except tomllib.TOMLDecodeError as err: + self.push_screen(AlertWindow(f"{import_file}: {err}")) + return if toml is not None: - self.handle.import_set( - import_file, - toml, - await scrape(toml.get_words(), toml.get_lang()), - ) + try: + self.handle.import_set( + import_file, + toml, + await scrape(toml.get_words(), toml.get_lang()), + ) + except IntegrityError as err: + set_id = self.handle.get_id_from_file_name(import_file) + if set_id is not None: + self.handle.delete_set(set_id) + self.push_screen(AlertWindow(f"{import_file}: {err}")) + return hex_string = import_file.encode("utf-8").hex() scrollable = self.query_one("#scrollable") @@ -155,17 +172,11 @@ async def on_complete_import(self): scrollable.mount( Horizontal( Checkbox(id=f"select-{hex_string}"), - Container(), Label(import_file), - Container(), - Button("Delete", id=f"delete-{hex_string}"), + classes="flashcard-set", ) ) - self.pop_screen() - - self.imports = {} - # pylint: disable=too-many-branches async def on_button_pressed(self, event: Button.Pressed): """ @@ -174,11 +185,12 @@ async def on_button_pressed(self, event: Button.Pressed): button_id = event.button.id if button_id is not None: if button_id == "import": + self.imports = {} self.push_screen(ImportPopup()) elif button_id == "start": self.on_start() elif button_id.startswith("delete"): - self.on_delete(button_id, event.button.parent) + self.on_delete() elif button_id == "exit": self.exit() elif button_id == "exit_study": @@ -394,3 +406,32 @@ async def on_button_pressed(self, event): await self.grade(4) elif event.button.id == "five": await self.grade(5) + + +class AlertWindow(ModalScreen): + """ + Alert window showing an error that occurred. + """ + + def __init__(self, message, *args, **kwargs): + super().__init__(*args, **kwargs) + self.message = message + + def compose(self): + yield Vertical( + Container(), + Horizontal(Container(), Label(self.message), Container()), + Container(), + ) + + def key_enter(self): + """ + Handle enter key press. + """ + self.dismiss() + + def on_click(self): + """ + Handle mouse click. + """ + self.dismiss()