[Addon] How can I call the import function to import my .ods?

Backstory

I have a .csv file that contains all of the questions, answers, and other card content. I always imported this .csv file.

However, LibreOffice doesn’t remember the size of the columns if I edit a .csv, but it does work with .ods; since I frequently edit that file to add new cards and update others, having to resize all of the columns every single time I open LibreOffice gets a chore.

So I decided to just use a .ods instead and then convert it via LibreOffice to a .csv file. I don’t want to go through the trouble of having to navigate through the gui or even having to manually run a shell script; I want no additional work in my importing workflow. That is why I created an addon that converts the file for me, while also triggering the import in Anki.

My Issue

I do not know how to do the last step, which is actually getting Anki to import that .csv that I selected via my addon.

The addon currently can

  1. select my .ods,
  2. convert it to .csv,
  3. (here it should import, but I don’t know how),
  4. remove the generated .csv.

Advice is much appreciated!

The Code

## Python imports ##
import os                          # check file exists; delete file
import sys                         # workaround to get psutil import working
import subprocess                  # run binary from system path

# import psutil from src/vendor, as Anki otherwise cannot access it (it was
# not part of Anki's .venv, probably since
# https://github.com/ankitects/anki/commit/bedab0a54b89819933dd1605fce105d9b60f639b)
# It had been installed with `pip install psutil -t src/vendor/`
vendor_path = os.path.join(os.path.dirname(__file__), "src", "vendor")

if vendor_path not in sys.path:
        sys.path.insert(0, vendor_path)

import psutil                      # find soffice process

## Anki imports ##
from aqt import mw                 # MainWindow
from aqt.utils import showCritical # "show critical" dialog
from aqt.qt import *


def is_libreoffice_running() -> bool:
        for process in psutil.process_iter(['name']):
                if process.info['name'] and 'soffice' in process.info['name'].lower():
                        return True
        return False


def convert_ods_to_csv(input_file, outdir) -> str:
        if is_libreoffice_running():
                print("Y")
                subprocess.run(["soffice", "--convert-to", "csv", "--outdir",
                                outdir, input_file])
        else:
                print("N")
                subprocess.run(["soffice", "--headless", "--convert-to",
                                "csv", "--outdir", outdir, input_file])
        
        input_file_basename = os.path.basename(input_file)
        output_file_name = os.path.splitext(input_file_basename)[0] + ".csv"
        output_file_path = os.path.join(outdir, output_file_name)
        
        return output_file_path


def check_file_exists(input_file) -> bool:
        if os.path.exists(input_file):
                return True
        
        showCritical("Error: '%s' No such file or directory." % input_file)
        return False


def remove_generated_csv(input_file) -> None:
        if check_file_exists(input_file):
                os.remove(input_file)


def import_ods() -> None:
        file_path, _ = QFileDialog.getOpenFileName(
                parent=mw,
                caption="Select a ODS file",
                filter="ODS Files (*.ods)"
        )
        
        if file_path and check_file_exists(file_path):
                csv_file = convert_ods_to_csv(file_path, "/tmp/")
                # TODO: now import that csv_file into the anki collection
                print(csv_file)
                remove_generated_csv(csv_file)
        

def create_menu_entry() -> None:
        action = QAction("Import .ods", mw)
        qconnect(action.triggered, import_ods)
        mw.form.menuTools.addAction(action)
        

create_menu_entry()

2 Likes

Here’s a basic example:

from anki.collection import Collection, ImportLogWithChanges
from anki.import_export_pb2 import CsvMetadata, ImportCsvRequest
from aqt import mw
from aqt.operations import CollectionOp
from aqt.qt import QAction, qconnect


def on_action() -> None:
    def op(col: Collection) -> ImportLogWithChanges:
        path = "test.csv"
        metadata = col.get_csv_metadata(
            path=path, delimiter=CsvMetadata.Delimiter.COMMA
        )
        request = ImportCsvRequest(path=path, metadata=metadata)
        return col.import_csv(request)

    CollectionOp(parent=mw, op=op).run_in_background()


action = QAction("Import CSV", mw)
qconnect(action.triggered, on_action)
mw.form.menuTools.addAction(action)
2 Likes

Thank you, this does seem to import the cards!

Can I also get the import screen working to know what has been imported / skipped ect? And ideally the other import screen as well, were import options can be set up?

Those ones:

Showing the import screen is simpler:

from aqt import mw
from aqt.import_export.importing import import_file
from aqt.qt import QAction, qconnect


def on_action() -> None:
    import_file(mw, "test.csv")


action = QAction("Import CSV", mw)
qconnect(action.triggered, on_action)
mw.form.menuTools.addAction(action)
1 Like

Thank you! I had seen this in Ankis codebase:

But I couldn’t figure out how to use that in my addon. Turns out, it was very simple.

Thanks for your help!

Related to that: The import_file() function doesn’t block the execution of this code of my addon:

        if file_path and check_file_exists(file_path):
                csv_file = convert_ods_to_csv(file_path, "/tmp/")
                import_file(mw, csv_file)
                remove_generated_csv(csv_file)

I thought maybe I should add await, but I get 'await' outside async function if I do this:

        if file_path and check_file_exists(file_path):
                csv_file = convert_ods_to_csv(file_path, "/tmp/")
                await import_file(mw, csv_file)
                remove_generated_csv(csv_file)

I couldn’t find a hook via

from aqt import gui_hooks
gui_hooks.

that looked useful, and there is no importing hook, only a few for exporting.

Do you know you I can block the execution until import_file() did finish? With the way it currently works the import dialog opens but shows an error as the file has already been deleted up to that point by my remove_generated_csv(csv_file).

It’s not an elegant solution, but this works:

from anki.hooks import wrap
from aqt import mw
from aqt.import_export.import_dialog import ImportDialog
from aqt.import_export.importing import import_file
from aqt.qt import QAction, qconnect


def on_action() -> None:
    import_file(mw, "test.csv")


def after_import_dialog_init(self: ImportDialog, *args, **kwargs) -> None:
    qconnect(self.finished, lambda: remove_generated_csv("test.csv"))


ImportDialog.__init__ = wrap(ImportDialog.__init__, after_import_dialog_init, "after")
action = QAction("Import CSV", mw)
qconnect(action.triggered, on_action)
mw.form.menuTools.addAction(action)
1 Like

Thanks, this works!

Would it be possible / acceptable to add a hook like e.g.

from aqt import gui_hooks
gui_hooks.media_check_did_finish

to Ankis code base? That way that workaround wouldn’t be needed. If yes, we could name it importer_did_finish.

Something like importer_dialog_did_init would be more useful probably, because it allows add-ons to hook on both dialog initialization and destruction (using the finished signal), and it’s the pattern used for other dialogs (e.g. add_cards_did_init).

1 Like