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
-
- 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.
- 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.
-
+
+ - 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. 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/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)