diff --git a/README.md b/README.md index a3175ee..afc42f7 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,13 @@ So you can no longer export a zip of your cards [without paying](https://www.ank This add-on salvages the cards from the SQLite database and was inspired by the Reddit post linked above. It can import cards, decks, note types, and media files. +![The add-on's dialog](images/dialog.png) + ## How to Use - Download the add-on from https://ankiweb.net/shared/info/2072125761 - Make sure all your AnkiApp decks are downloaded before using the add-on. For that, go to AnkiApp, click on each of your decks, then click on the Download button at the bottom if it's shown. -- Run Anki and go to **Tools > Import From AnkiApp** and choose AnkiApp's database file (`C:\Users\%USERNAME%\AppData\Roaming\AnkiApp\databases\file__0` on Windows; `~/Library/Application Support/AnkiApp/databases/file__0/1` or under `~/Library/Containers/com.ankiapp.client/Data/Documents/ankiapp` on macOS). -- Grab a cup of coffee while waiting for importing to finish. +- Run Anki and go to **Tools > Import From AnkiApp**. The add-on tries to detect AnkiApp's data folder on your system automatically. If you see the "Data folder" field already populated, you can go ahead and click Import. ## Notes & Known Issues diff --git a/ankiweb_page.html b/ankiweb_page.html index 56a4415..4b21318 100644 --- a/ankiweb_page.html +++ b/ankiweb_page.html @@ -4,8 +4,10 @@ How to Use - - + Notes & Known Issues diff --git a/designer/dialog.ui b/designer/dialog.ui new file mode 100644 index 0000000..d58c773 --- /dev/null +++ b/designer/dialog.ui @@ -0,0 +1,114 @@ + + + Dialog + + + + 0 + 0 + 500 + 200 + + + + + 0 + 0 + + + + Dialog + + + + + + + 0 + 0 + + + + + + + + + + AnkiApp data folder + + + Data folder + + + true + + + + + + + + + + + + ... + + + + + + + + + AnkiApp's SQLite database file. If you only have access to a single database file, you can use this, otherwise it's recommended to specify the data folder instead to allow the add-on to import more data + + + Database file + + + + + + + + + false + + + + + + + false + + + ... + + + + + + + + + + + + Import + + + + + + + Try to download missing media from AnkiApp servers + + + + + + + + diff --git a/images/dialog.png b/images/dialog.png new file mode 100644 index 0000000..40b95cd Binary files /dev/null and b/images/dialog.png differ diff --git a/requirements/bundle.in b/requirements/bundle.in index b2e3711..087a2bc 100644 --- a/requirements/bundle.in +++ b/requirements/bundle.in @@ -1 +1 @@ -ankiutils @ git+https://github.com/abdnh/ankiutils@c699f193f35dcd5d8995ddc78b22d03642927018 +ankiutils @ git+https://github.com/abdnh/ankiutils@a287daa1d0f84b08c623165d4ccdd4637e2d714c diff --git a/requirements/bundle.txt b/requirements/bundle.txt index 9b91a8e..679b94e 100644 --- a/requirements/bundle.txt +++ b/requirements/bundle.txt @@ -1,2 +1,2 @@ -ankiutils @ git+https://github.com/abdnh/ankiutils@c699f193f35dcd5d8995ddc78b22d03642927018 +ankiutils @ git+https://github.com/abdnh/ankiutils@a287daa1d0f84b08c623165d4ccdd4637e2d714c # via -r requirements/bundle.in diff --git a/requirements/dev.txt b/requirements/dev.txt index 80c5cd8..d56cd69 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -2,7 +2,7 @@ anki==2.1.66 # via aqt ankiscripts @ git+https://github.com/abdnh/ankiscripts@1523f27088ff88cd0bca9cfb2ec9617eddc5e1e2 # via -r requirements/dev.in -ankiutils @ git+https://github.com/abdnh/ankiutils@c699f193f35dcd5d8995ddc78b22d03642927018 +ankiutils @ git+https://github.com/abdnh/ankiutils@a287daa1d0f84b08c623165d4ccdd4637e2d714c # via -r requirements\bundle.in aqt==2.1.66 # via -r requirements/dev.in @@ -156,7 +156,7 @@ pytest==7.4.2 # pytest-cov pytest-cov==4.1.0 # via -r requirements/dev.in -pyupgrade==3.11.2 +pyupgrade==3.12.0 # via -r requirements/dev.in pywin32==306 ; sys_platform == "win32" # via diff --git a/src/__init__.py b/src/__init__.py index f7785d5..df07873 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,13 +1,13 @@ import os import sys from concurrent.futures import Future +from pathlib import Path from textwrap import dedent from typing import Optional from aqt import mw -from aqt.gui_hooks import main_window_did_init from aqt.qt import QAction, qconnect -from aqt.utils import getFile, showText, showWarning, tooltip +from aqt.utils import showText, showWarning, tooltip sys.path.append(os.path.join(os.path.dirname(__file__), "vendor")) @@ -17,9 +17,10 @@ AnkiAppImporterException, ) from .consts import consts +from .gui.dialog import Dialog -def import_from_ankiapp(filename: str) -> None: +def import_from_ankiapp(db_path: Path) -> None: mw.progress.start( label="Extracting collection from AnkiApp database...", immediate=True, @@ -27,7 +28,7 @@ def import_from_ankiapp(filename: str) -> None: mw.progress.set_title(consts.name) def start_importing() -> Optional[tuple[int, set[str]]]: - importer = AnkiAppImporter(mw, filename) + importer = AnkiAppImporter(mw, db_path) return importer.import_to_anki(), importer.warnings def on_done(fut: Future) -> None: @@ -66,28 +67,17 @@ def on_done(fut: Future) -> None: mw.taskman.run_in_background(start_importing, on_done) -def on_mw_init() -> None: - action = QAction(mw) - action.setText("Import From AnkiApp") - mw.form.menuTools.addAction(action) +action = QAction(mw) +action.setText("Import From AnkiApp") +mw.form.menuTools.addAction(action) - def on_triggered() -> None: - file = getFile( - mw, - "AnkiApp database file to import", - key="AnkiAppImporter", - cb=None, - filter="*", - ) - if not file: - return - assert isinstance(file, str) - import_from_ankiapp(file) - qconnect( - action.triggered, - on_triggered, - ) +def on_action() -> None: + dialog = Dialog(mw, on_done=import_from_ankiapp) + dialog.open() -main_window_did_init.append(on_mw_init) +qconnect( + action.triggered, + on_action, +) diff --git a/src/ankiapp_importer.py b/src/ankiapp_importer.py index be14511..6c5ad73 100644 --- a/src/ankiapp_importer.py +++ b/src/ankiapp_importer.py @@ -8,6 +8,7 @@ import urllib from collections.abc import Iterable, Iterator, MutableSet from mimetypes import guess_extension +from pathlib import Path from re import Match from textwrap import dedent from typing import Any, Dict, List, Optional, cast @@ -236,7 +237,7 @@ class AnkiAppImporterCanceledException(AnkiAppImporterException): # pylint: disable=too-few-public-methods,too-many-instance-attributes class AnkiAppImporter: - def __init__(self, mw: AnkiQt, filename: str): + def __init__(self, mw: AnkiQt, db_path: Path): self.mw = mw self.BLOB_REF_PATTERNS = ( # Use Anki's HTML media patterns too for completeness @@ -254,7 +255,7 @@ def __init__(self, mw: AnkiQt, filename: str): ), ) self.config = mw.addonManager.getConfig(__name__) - self.con = sqlite3.connect(filename) + self.con = sqlite3.connect(db_path) self._extract_notetypes() self._extract_decks() self._extract_media() diff --git a/src/appdata.py b/src/appdata.py new file mode 100644 index 0000000..0a57888 --- /dev/null +++ b/src/appdata.py @@ -0,0 +1,57 @@ +import functools +import os +import sqlite3 +from pathlib import Path +from typing import List, Optional, Union + +try: + from anki.utils import is_mac, is_win +except ImportError: + from anki.utils import isMac as is_mac # type: ignore + from anki.utils import isWin as is_win # type: ignore + + +def get_ankiapp_data_folder() -> Optional[str]: + path = None + if is_win: + from aqt.winpaths import get_appdata + + path = os.path.join(get_appdata(), "AnkiApp") + elif is_mac: + path = os.path.expanduser("~/Library/Application Support/AnkiApp") + if not os.path.exists(path): + # App store verison + path = os.path.expanduser( + "~/Library/Containers/com.ankiapp.client/Data/Documents/ankiapp" + ) + + if path is not None and os.path.exists(path): + return path + return None + + +# pylint: disable=too-few-public-methods +class AnkiAppData: + def __init__(self, path: Union[Path, str]): + self.path = Path(path) + + @functools.cached_property + def sqlite_dbs(self) -> List[Path]: + databases_path = self.path / "databases" + databases_db_path = databases_path / "Databases.db" + if not databases_db_path.exists(): + return [] + with sqlite3.connect(databases_db_path) as conn: + db_paths = [] + for row in conn.execute("select origin from Databases"): + # Use the first file found in the database subfolder + # TODO: inevstigate whether this can cause problems + db_path = next((databases_path / str(row[0])).iterdir(), None) + if db_path: + db_paths.append(db_path) + return db_paths + + +if __name__ == "__main__": + appdata = AnkiAppData(get_ankiapp_data_folder()) + print(appdata.sqlite_dbs) diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gui/dialog.py b/src/gui/dialog.py new file mode 100644 index 0000000..b22b581 --- /dev/null +++ b/src/gui/dialog.py @@ -0,0 +1,86 @@ +from pathlib import Path +from typing import Optional + +import ankiutils.gui.dialog +from aqt.qt import * +from aqt.utils import getFile, showWarning + +from ..appdata import AnkiAppData, get_ankiapp_data_folder +from ..config import config +from ..consts import consts +from ..forms.dialog import Ui_Dialog + + +class Dialog(ankiutils.gui.dialog.Dialog): + def __init__( + self, + parent: Optional[QWidget] = None, + on_done: Callable[[Path], None] = None, + ) -> None: + super().__init__(__name__, parent) + self._on_done = on_done + + def setup_ui(self) -> None: + self.form = Ui_Dialog() + self.form.setupUi(self) + super().setup_ui() + self.setWindowTitle(consts.name) + + qconnect(self.form.data_folder_checkbox.toggled, self.on_data_folder_toggled) + qconnect( + self.form.database_file_checkbox.toggled, self.on_database_file_toggled + ) + self.form.remote_media.setChecked(config["remote_media"]) + qconnect(self.form.remote_media.toggled, self.on_remote_media_toggled) + ankiapp_data_folder = get_ankiapp_data_folder() + if ankiapp_data_folder: + self.form.data_folder.setText(ankiapp_data_folder) + qconnect(self.form.choose_data_folder.clicked, self.on_choose_data_folder) + qconnect(self.form.choose_database_file.clicked, self.on_choose_database_file) + qconnect(self.form.import_button.clicked, self.on_import) + + def on_data_folder_toggled(self, checked: bool) -> None: + if checked: + self.form.data_folder.setEnabled(True) + self.form.choose_data_folder.setEnabled(True) + self.form.database_file.setEnabled(False) + self.form.choose_database_file.setEnabled(False) + + def on_database_file_toggled(self, checked: bool) -> None: + if checked: + self.form.database_file.setEnabled(True) + self.form.choose_database_file.setEnabled(True) + self.form.data_folder.setEnabled(False) + self.form.choose_data_folder.setEnabled(False) + + def on_remote_media_toggled(self, checked: bool) -> None: + config["remote_media"] = checked + + def on_choose_data_folder(self) -> None: + folder = QFileDialog.getExistingDirectory(self, "Choose AnkiApp data folder") + if folder: + self.form.data_folder.setText(folder) + + def on_choose_database_file(self) -> None: + file = getFile(self, consts.name, cb=None, filter="*") + assert isinstance(file, str) + if file: + file = os.path.normpath(file) + self.form.database_file.setText(file) + + def on_import(self) -> None: + db_path: Optional[Path] = None + if self.form.database_file_checkbox.isChecked(): + db_path = Path(self.form.database_file.text()) + else: + appdata = AnkiAppData(self.form.data_folder.text()) + if appdata.sqlite_dbs: + db_path = appdata.sqlite_dbs[0] + if not db_path or not db_path.exists(): + showWarning( + "Path is empty or doesn't exist", parent=self, title=consts.name + ) + return + + self.accept() + self._on_done(db_path)