Skip to content

Commit

Permalink
Add Edit dialog (FooSoft#306)
Browse files Browse the repository at this point in the history
* Add Edit dialog

Like Edit Current, but:
  * has a Preview button to preview all cards for this note
  * has a Browse button to open the browser with these
  * has Previous/Back buttons to navigate the history of the dialog
  * has no Close button bar

* Fix api method `deleteDecks`

The method was failing due to Anki API changes.

Also, make it mandatory to call the method with `cardsToo=True`,
since deleting decks on Anki >= 2.1.28 without cards is
no longer supported, and the deprecated `decks.rem()` method
on Anki >= 2.1.45 ignores keyword arguments.

* Fix api method `findAndReplaceInModels`

I think this was a typo?

* Remove api method `updateCompleteDeck`

This method was broken and there are no issues regarding it on GitHub.

* Make `plugin` importable

Don't start the web server if imported not from inside Anki

Make sure Anki Connect instance is not garbage collected.
This kills the timer that's responsible for the web server.

* Edit dialog: refactor

* make previewer use a more generic Adapter to flip through cards;
* return Previewer from `show_preview()` for testing,
* as well as Edit dialog from `open_dialog_and_show_note_with_id`;
* disable/enable `Edit` editor buttons more reliably;
* a few minor changes

* Move configuration defaults dict to module level

So that it is importable by tests

* Do not wrap api methods with a lambda

Makes Pycharm happy

* Convert all tests to pytest

Previously, tests were run against Anki launched by user.

Now,
 * most tests run against isolated Anki in current process;
 * tests in `test_server.py` launch another Anki in a separate process
   and run a few commands to test the server;
 * nearly all tests were preserved in the sense that
   what was being tested is tested still.
   A few tests in `test_graphical.py` are skipped due to
   a problem with the method tests, see the comments;
 * tests can be run:
   * In a single profile, using --no-tear-down-profile-after-each-test;
   * In a single app instance, but with the profile being torn down
     after each test--default;
   * In separate processes, using --forked.

* Add tests for the Edit dialog

* Add `tox.ini`, remove `test.sh`

The tests can be run now using `tox` against multiple Anki versions;
see instructions in `tox.ini`.

The tests depend on `pytest-anki` that had to be slightly modified
to remove the upper constraint on Anki version, as well as
to remove a few dependencies that are not essential to using it.

* Add api method `guiEditNote`

* Add GitHub workflows tests

* Ignore option `closeAfterAdding` of `guiAddCards`

The functionality was broken, creating a dialog that was not,
in fact, closing after adding a card.

See the deleted comment in `test_graphical.py`

* Tests: simplify profile removal

It turns out that `pytest-anki` does what we are trying to do already.

Note that `empty_anki_session_started` creates a temporary user too.
We are “overwriting“ it in `profile_created_and_loaded`
by calling `temporary_user`. It seems that doing this is safe.

* Edit dialog: make browser button show all history

Before, pressing the Browse button would only show browser
with the cards or notes corresponding to the currently edited note.

Now, it shows all cards or notes from the dialog history,
in reverse order (last seen on top),
with the currently edited note or its cards selected.

* Tests: patch `waitress` to reduce test flakiness

Waitress is a WSGI server that Anki starts to serve css etc to
its web views. It seems to have a race condition issue;
the main loop thread is trying to `select.select` the sockets
which a worker thread is closing because of a dead connection.

This makes waitress skip actually closing the sockets.
  • Loading branch information
oakkitten authored Apr 20, 2022
1 parent 04482f4 commit 41d5904
Show file tree
Hide file tree
Showing 20 changed files with 1,774 additions and 861 deletions.
25 changes: 25 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Tests

on: [push, pull_request, workflow_dispatch]

jobs:
run-tests:
name: Run tests
runs-on: ubuntu-latest
steps:
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y pyqt5-dev-tools xvfb
- name: Setup Python
uses: actions/setup-python@v2

- name: Install tox
run: pip install tox

- name: Checkout repository
uses: actions/checkout@v2

- name: Run tests
run: tox -- --forked --verbose
108 changes: 30 additions & 78 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -745,8 +745,8 @@ corresponding to when the API was available for use.
* **deleteDecks**
Deletes decks with the given names. If `cardsToo` is `true` (defaults to `false` if unspecified), the cards within
the deleted decks will also be deleted; otherwise they will be moved to the default deck.
Deletes decks with the given names.
The argument `cardsToo` *must* be specified and set to `true`.
*Sample request*:
```json
Expand Down Expand Up @@ -960,76 +960,6 @@ corresponding to when the API was available for use.
}
```
* **updateCompleteDeck**
Pastes all transmitted data into the database and reloads the collection.
You can send a deckName and corresponding cards, notes and models.
All cards are assumed to belong to the given deck.
All notes referenced by given cards should be present.
All models referenced by given notes should be present.
*Sample request*:
```json
{
"action": "updateCompleteDeck",
"version": 6,
"params": {
"data": {
"deck": "test3",
"cards": {
"1485369472028": {
"id": 1485369472028,
"nid": 1485369340204,
"ord": 0,
"type": 0,
"queue": 0,
"due": 1186031,
"factor": 0,
"ivl": 0,
"reps": 0,
"lapses": 0,
"left": 0
}
},
"notes": {
"1485369340204": {
"id": 1485369340204,
"mid": 1375786181313,
"fields": [
"frontValue",
"backValue"
],
"tags": [
"aTag"
]
}
},
"models": {
"1375786181313": {
"id": 1375786181313,
"name": "anotherModel",
"fields": [
"Front",
"Back"
],
"templateNames": [
"Card 1"
]
}
}
}
}
}
```
*Sample result*:
```json
{
"result": null,
"error": null
}
```
#### Graphical Actions
* **guiBrowse**
Expand Down Expand Up @@ -1085,9 +1015,6 @@ corresponding to when the API was available for use.
Audio, video, and picture files can be embedded into the fields via the `audio`, `video`, and `picture` keys, respectively.
Refer to the documentation of `addNote` and `storeMediaFile` for an explanation of these fields.
The `closeAfterAdding` member inside `options` group can be set to true to create a dialog that closes upon adding the note.
Invoking the action mutliple times with this option will create _multiple windows_.
The result is the ID of the note which would be added, if the user chose to confirm the *Add Cards* dialogue.
*Sample request*:
Expand All @@ -1103,9 +1030,6 @@ corresponding to when the API was available for use.
"Text": "The capital of Romania is {{c1::Bucharest}}",
"Extra": "Romania is a country in Europe"
},
"options": {
"closeAfterAdding": true
},
"tags": [
"countries"
],
Expand All @@ -1129,6 +1053,34 @@ corresponding to when the API was available for use.
}
```
* **guiEditNote**
Opens the *Edit* dialog with a note corresponding to given note ID.
The dialog is similar to the *Edit Current* dialog, but:
* has a Preview button to preview the cards for the note
* has a Browse button to open the browser with these cards
* has Previous/Back buttons to navigate the history of the dialog
* has no bar with the Close button
*Sample request*:
```json
{
"action": "guiEditNote",
"version": 6,
"params": {
"note": 1649198355435
}
}
```
*Sample result*:
```json
{
"result": null,
"error": null
}
```
* **guiCurrentCard**
Returns information about the current card or `null` if not in review mode.
Expand Down
163 changes: 34 additions & 129 deletions plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
from anki.exporting import AnkiPackageExporter
from anki.importing import AnkiPackageImporter
from anki.notes import Note
from anki.utils import joinFields, intTime, guid64, fieldChecksum

from .edit import Edit

try:
from anki.rsbackend import NotFoundError
Expand All @@ -59,14 +60,19 @@ class AnkiConnect:

def __init__(self):
self.log = None
self.timer = None
self.server = web.WebServer(self.handler)

def initLogging(self):
logPath = util.setting('apiLogPath')
if logPath is not None:
self.log = open(logPath, 'w')

def startWebServer(self):
try:
self.server = web.WebServer(self.handler)
self.server.listen()

# only keep reference to prevent garbage collection
self.timer = QTimer()
self.timer.timeout.connect(self.advance)
self.timer.start(util.setting('apiPollInterval'))
Expand Down Expand Up @@ -534,12 +540,22 @@ def changeDeck(self, cards, deck):

@util.api()
def deleteDecks(self, decks, cardsToo=False):
if not cardsToo:
# since f592672fa952260655881a75a2e3c921b2e23857 (2.1.28)
# (see anki$ git log "-Gassert cardsToo")
# you can't delete decks without deleting cards as well.
# however, since 62c23c6816adf912776b9378c008a52bb50b2e8d (2.1.45)
# passing cardsToo to `rem` (long deprecated) won't raise an error!
# this is dangerous, so let's raise our own exception
if self._anki21_version >= 28:
raise Exception("Since Anki 2.1.28 it's not possible "
"to delete decks without deleting cards as well")
try:
self.startEditing()
decks = filter(lambda d: d in self.deckNames(), decks)
for deck in decks:
did = self.decks().id(deck)
self.decks().rem(did, cardsToo)
self.decks().rem(did, cardsToo=cardsToo)
finally:
self.stopEditing()

Expand Down Expand Up @@ -1131,7 +1147,7 @@ def findAndReplaceInModels(self, modelName, findText, replaceText, front=True, b
model = self.collection().models.byName(modelName)
if model is None:
raise Exception('model was not found: {}'.format(modelName))
ankiModel = [model]
ankiModel = [modelName]
updatedModels = 0
for model in ankiModel:
model = self.collection().models.byName(model)
Expand Down Expand Up @@ -1280,45 +1296,6 @@ def getLatestReviewID(self, deck):
) or 0


@util.api()
def updateCompleteDeck(self, data):
self.startEditing()
did = self.decks().id(data['deck'])
self.decks().flush()
model_manager = self.collection().models
for _, card in data['cards'].items():
self.database().execute(
'replace into cards (id, nid, did, ord, type, queue, due, ivl, factor, reps, lapses, left, '
'mod, usn, odue, odid, flags, data) '
'values (' + '?,' * (12 + 6 - 1) + '?)',
card['id'], card['nid'], did, card['ord'], card['type'], card['queue'], card['due'],
card['ivl'], card['factor'], card['reps'], card['lapses'], card['left'],
intTime(), -1, 0, 0, 0, 0
)
note = data['notes'][str(card['nid'])]
tags = self.collection().tags.join(self.collection().tags.canonify(note['tags']))
self.database().execute(
'replace into notes(id, mid, tags, flds,'
'guid, mod, usn, flags, data, sfld, csum) values (' + '?,' * (4 + 7 - 1) + '?)',
note['id'], note['mid'], tags, joinFields(note['fields']),
guid64(), intTime(), -1, 0, 0, '', fieldChecksum(note['fields'][0])
)
model = data['models'][str(note['mid'])]
if not model_manager.get(model['id']):
model_o = model_manager.new(model['name'])
for field_name in model['fields']:
field = model_manager.newField(field_name)
model_manager.addField(model_o, field)
for template_name in model['templateNames']:
template = model_manager.newTemplate(template_name)
model_manager.addTemplate(model_o, template)
model_o['id'] = model['id']
model_manager.update(model_o)
model_manager.flush()

self.stopEditing()


@util.api()
def insertReviews(self, reviews):
if len(reviews) > 0:
Expand Down Expand Up @@ -1395,6 +1372,12 @@ def guiBrowse(self, query=None):

return self.findCards(query)


@util.api()
def guiEditNote(self, note):
Edit.open_dialog_and_show_note_with_id(note)


@util.api()
def guiSelectedNotes(self):
(creator, instance) = aqt.dialogs._dialogs['Browser']
Expand All @@ -1421,91 +1404,6 @@ def guiAddCards(self, note=None):
collection.models.setCurrent(model)
collection.models.update(model)

closeAfterAdding = False
if note is not None and 'options' in note:
if 'closeAfterAdding' in note['options']:
closeAfterAdding = note['options']['closeAfterAdding']
if type(closeAfterAdding) is not bool:
raise Exception('option parameter \'closeAfterAdding\' must be boolean')

addCards = None

if closeAfterAdding:
randomString = ''.join(random.choice(string.ascii_letters) for _ in range(10))
windowName = 'AddCardsAndClose' + randomString

class AddCardsAndClose(aqt.addcards.AddCards):

def __init__(self, mw):
# the window must only reset if
# * function `onModelChange` has been called prior
# * window was newly opened

self.modelHasChanged = True
super().__init__(mw)

self.addButton.setText('Add and Close')
self.addButton.setShortcut(aqt.qt.QKeySequence('Ctrl+Return'))

def _addCards(self):
super()._addCards()

# if adding was successful it must mean it was added to the history of the window
if len(self.history):
self.reject()

def onModelChange(self):
if self.isActiveWindow():
super().onModelChange()
self.modelHasChanged = True

def onReset(self, model=None, keep=False):
if self.isActiveWindow() or self.modelHasChanged:
super().onReset(model, keep)
self.modelHasChanged = False

else:
# modelchoosers text is changed by a reset hook
# therefore we need to change it back manually
self.modelChooser.models.setText(self.editor.note.model()['name'])
self.modelHasChanged = False

def _reject(self):
savedMarkClosed = aqt.dialogs.markClosed
aqt.dialogs.markClosed = lambda _: savedMarkClosed(windowName)
super()._reject()
aqt.dialogs.markClosed = savedMarkClosed

aqt.dialogs._dialogs[windowName] = [AddCardsAndClose, None]
addCards = aqt.dialogs.open(windowName, self.window())

if savedMid:
deck['mid'] = savedMid

editor = addCards.editor
ankiNote = editor.note

if 'fields' in note:
for name, value in note['fields'].items():
if name in ankiNote:
ankiNote[name] = value

self.addMediaFromNote(ankiNote, note)
editor.loadNote()

if 'tags' in note:
ankiNote.tags = note['tags']
editor.updateTags()

# if Anki does not Focus, the window will not notice that the
# fields are actually filled
aqt.dialogs.open(windowName, self.window())
addCards.setAndFocusNote(editor.note)

return ankiNote.id

elif note is not None:
collection = self.collection()
ankiNote = anki.notes.Note(collection, model)

# fill out card beforehand, so we can be sure of the note id
Expand Down Expand Up @@ -1732,4 +1630,11 @@ def importPackage(self, path):
# Entry
#

ac = AnkiConnect()
# when run inside Anki, `__name__` would be either numeric,
# or, if installed via `link.sh`, `AnkiConnectDev`
if __name__ != "plugin":
Edit.register_with_anki()

ac = AnkiConnect()
ac.initLogging()
ac.startWebServer()
Loading

0 comments on commit 41d5904

Please sign in to comment.