init
This commit is contained in:
		
							
								
								
									
										2177
									
								
								plugin/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2177
									
								
								plugin/__init__.py
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										8
									
								
								plugin/config.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								plugin/config.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
    "apiKey": null,
 | 
			
		||||
    "apiLogPath": null,
 | 
			
		||||
    "webBindAddress": "127.0.0.1",
 | 
			
		||||
    "webBindPort": 8765,
 | 
			
		||||
    "webCorsOriginList": ["http://localhost"],
 | 
			
		||||
    "ignoreOriginList": []
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								plugin/config.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								plugin/config.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
Read the documentation on the [AnkiConnect](https://foosoft.net/projects/anki-connect/) project page for details.
 | 
			
		||||
							
								
								
									
										458
									
								
								plugin/edit.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										458
									
								
								plugin/edit.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,458 @@
 | 
			
		||||
import aqt
 | 
			
		||||
import aqt.editor
 | 
			
		||||
import aqt.browser.previewer
 | 
			
		||||
from aqt import gui_hooks
 | 
			
		||||
from aqt.qt import Qt, QKeySequence, QShortcut, QCloseEvent, QMainWindow
 | 
			
		||||
from aqt.utils import restoreGeom, saveGeom, tooltip
 | 
			
		||||
from anki.errors import NotFoundError
 | 
			
		||||
from anki.consts import QUEUE_TYPE_SUSPENDED
 | 
			
		||||
from anki.utils import ids2str
 | 
			
		||||
 | 
			
		||||
from . import anki_version
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Edit dialog. Like Edit Current, but:
 | 
			
		||||
#   * has a Preview button to preview the cards for the note
 | 
			
		||||
#   * has Previous/Back buttons to navigate the history of the dialog
 | 
			
		||||
#   * has a Browse button to open the history in the Browser
 | 
			
		||||
#   * has no bar with the Close button
 | 
			
		||||
#
 | 
			
		||||
# To register in Anki's dialog system:
 | 
			
		||||
# > from .edit import Edit
 | 
			
		||||
# > Edit.register_with_anki()
 | 
			
		||||
#
 | 
			
		||||
# To (re)open (note_id is an integer):
 | 
			
		||||
# > Edit.open_dialog_and_show_note_with_id(note_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
DOMAIN_PREFIX = "foosoft.ankiconnect."
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_note_by_note_id(note_id):
 | 
			
		||||
    return aqt.mw.col.get_note(note_id)
 | 
			
		||||
 | 
			
		||||
def is_card_suspended(card):
 | 
			
		||||
    return card.queue == QUEUE_TYPE_SUSPENDED
 | 
			
		||||
 | 
			
		||||
def filter_valid_note_ids(note_ids):
 | 
			
		||||
    return aqt.mw.col.db.list(
 | 
			
		||||
        "select id from notes where id in " + ids2str(note_ids)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
##############################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DecentPreviewer(aqt.browser.previewer.MultiCardPreviewer):
 | 
			
		||||
    class Adapter:
 | 
			
		||||
        def get_current_card(self): raise NotImplementedError
 | 
			
		||||
        def can_select_previous_card(self): raise NotImplementedError
 | 
			
		||||
        def can_select_next_card(self): raise NotImplementedError
 | 
			
		||||
        def select_previous_card(self): raise NotImplementedError
 | 
			
		||||
        def select_next_card(self): raise NotImplementedError
 | 
			
		||||
 | 
			
		||||
    def __init__(self, adapter: Adapter):
 | 
			
		||||
        super().__init__(parent=None, mw=aqt.mw, on_close=lambda: None) # noqa
 | 
			
		||||
        self.adapter = adapter
 | 
			
		||||
        self.last_card_id = 0
 | 
			
		||||
 | 
			
		||||
    def card(self):
 | 
			
		||||
        return self.adapter.get_current_card()
 | 
			
		||||
 | 
			
		||||
    def card_changed(self):
 | 
			
		||||
        current_card_id = self.adapter.get_current_card().id
 | 
			
		||||
        changed = self.last_card_id != current_card_id
 | 
			
		||||
        self.last_card_id = current_card_id
 | 
			
		||||
        return changed
 | 
			
		||||
 | 
			
		||||
    # the check if we can select next/previous card is needed because
 | 
			
		||||
    # the buttons sometimes get disabled a tad too late
 | 
			
		||||
    # and can still be pressed by user.
 | 
			
		||||
    # this is likely due to Anki sometimes delaying rendering of cards
 | 
			
		||||
    # in order to avoid rendering them too fast?
 | 
			
		||||
    def _on_prev_card(self):
 | 
			
		||||
        if self.adapter.can_select_previous_card():
 | 
			
		||||
            self.adapter.select_previous_card()
 | 
			
		||||
            self.render_card()
 | 
			
		||||
 | 
			
		||||
    def _on_next_card(self):
 | 
			
		||||
        if self.adapter.can_select_next_card():
 | 
			
		||||
            self.adapter.select_next_card()
 | 
			
		||||
            self.render_card()
 | 
			
		||||
 | 
			
		||||
    def _should_enable_prev(self):
 | 
			
		||||
        return self.showing_answer_and_can_show_question() or \
 | 
			
		||||
               self.adapter.can_select_previous_card()
 | 
			
		||||
 | 
			
		||||
    def _should_enable_next(self):
 | 
			
		||||
        return self.showing_question_and_can_show_answer() or \
 | 
			
		||||
               self.adapter.can_select_next_card()
 | 
			
		||||
 | 
			
		||||
    def _render_scheduled(self):
 | 
			
		||||
        super()._render_scheduled()  # noqa
 | 
			
		||||
        self._updateButtons()
 | 
			
		||||
 | 
			
		||||
    def showing_answer_and_can_show_question(self):
 | 
			
		||||
        return self._state == "answer" and not self._show_both_sides
 | 
			
		||||
 | 
			
		||||
    def showing_question_and_can_show_answer(self):
 | 
			
		||||
        return self._state == "question"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ReadyCardsAdapter(DecentPreviewer.Adapter):
 | 
			
		||||
    def __init__(self, cards):
 | 
			
		||||
        self.cards = cards
 | 
			
		||||
        self.current = 0
 | 
			
		||||
 | 
			
		||||
    def get_current_card(self):
 | 
			
		||||
        return self.cards[self.current]
 | 
			
		||||
 | 
			
		||||
    def can_select_previous_card(self):
 | 
			
		||||
        return self.current > 0
 | 
			
		||||
 | 
			
		||||
    def can_select_next_card(self):
 | 
			
		||||
        return self.current < len(self.cards) - 1
 | 
			
		||||
 | 
			
		||||
    def select_previous_card(self):
 | 
			
		||||
        self.current -= 1
 | 
			
		||||
 | 
			
		||||
    def select_next_card(self):
 | 
			
		||||
        self.current += 1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
##############################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# store note ids instead of notes, as note objects don't implement __eq__ etc
 | 
			
		||||
class History:
 | 
			
		||||
    number_of_notes_to_keep_in_history = 25
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.note_ids = []
 | 
			
		||||
 | 
			
		||||
    def append(self, note):
 | 
			
		||||
        if note.id in self.note_ids:
 | 
			
		||||
            self.note_ids.remove(note.id)
 | 
			
		||||
        self.note_ids.append(note.id)
 | 
			
		||||
        self.note_ids = self.note_ids[-self.number_of_notes_to_keep_in_history:]
 | 
			
		||||
 | 
			
		||||
    def has_note_to_left_of(self, note):
 | 
			
		||||
        return note.id in self.note_ids and note.id != self.note_ids[0]
 | 
			
		||||
 | 
			
		||||
    def has_note_to_right_of(self, note):
 | 
			
		||||
        return note.id in self.note_ids and note.id != self.note_ids[-1]
 | 
			
		||||
 | 
			
		||||
    def get_note_to_left_of(self, note):
 | 
			
		||||
        note_id = self.note_ids[self.note_ids.index(note.id) - 1]
 | 
			
		||||
        return get_note_by_note_id(note_id)
 | 
			
		||||
 | 
			
		||||
    def get_note_to_right_of(self, note):
 | 
			
		||||
        note_id = self.note_ids[self.note_ids.index(note.id) + 1]
 | 
			
		||||
        return get_note_by_note_id(note_id)
 | 
			
		||||
 | 
			
		||||
    def get_last_note(self):            # throws IndexError if history empty
 | 
			
		||||
        return get_note_by_note_id(self.note_ids[-1])
 | 
			
		||||
 | 
			
		||||
    def remove_invalid_notes(self):
 | 
			
		||||
        self.note_ids = filter_valid_note_ids(self.note_ids)
 | 
			
		||||
 | 
			
		||||
history = History()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# see method `find_cards` of `collection.py`
 | 
			
		||||
def trigger_search_for_dialog_history_notes(search_context, use_history_order):
 | 
			
		||||
    search_context.search = " or ".join(
 | 
			
		||||
        f"nid:{note_id}" for note_id in history.note_ids
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if use_history_order:
 | 
			
		||||
        search_context.order = f"""case c.nid {
 | 
			
		||||
            " ".join(
 | 
			
		||||
                f"when {note_id} then {n}"
 | 
			
		||||
                for (n, note_id) in enumerate(reversed(history.note_ids))
 | 
			
		||||
            )
 | 
			
		||||
        } end asc"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
##############################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# noinspection PyAttributeOutsideInit
 | 
			
		||||
class Edit(aqt.editcurrent.EditCurrent):
 | 
			
		||||
    dialog_geometry_tag = DOMAIN_PREFIX + "edit"
 | 
			
		||||
    dialog_registry_tag = DOMAIN_PREFIX + "Edit"
 | 
			
		||||
    dialog_search_tag = DOMAIN_PREFIX + "edit.history"
 | 
			
		||||
 | 
			
		||||
    # depending on whether the dialog already exists, 
 | 
			
		||||
    # upon a request to open the dialog via `aqt.dialogs.open()`,
 | 
			
		||||
    # the manager will call either the constructor or the `reopen` method
 | 
			
		||||
    def __init__(self, note):
 | 
			
		||||
        QMainWindow.__init__(self, None, Qt.WindowType.Window)
 | 
			
		||||
        self.form = aqt.forms.editcurrent.Ui_Dialog()
 | 
			
		||||
        self.form.setupUi(self)
 | 
			
		||||
        self.setWindowTitle("Edit")
 | 
			
		||||
        self.setMinimumWidth(250)
 | 
			
		||||
        self.setMinimumHeight(400)
 | 
			
		||||
        restoreGeom(self, self.dialog_geometry_tag)
 | 
			
		||||
 | 
			
		||||
        self.form.buttonBox.setVisible(False)   # hides the Close button bar
 | 
			
		||||
        self.setup_editor_buttons()
 | 
			
		||||
 | 
			
		||||
        self.show()
 | 
			
		||||
        self.bring_to_foreground()
 | 
			
		||||
 | 
			
		||||
        history.remove_invalid_notes()
 | 
			
		||||
        history.append(note)
 | 
			
		||||
        self.show_note(note)
 | 
			
		||||
 | 
			
		||||
        gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
 | 
			
		||||
        gui_hooks.editor_did_load_note.append(self.editor_did_load_note)
 | 
			
		||||
 | 
			
		||||
    def reopen(self, note):
 | 
			
		||||
        history.append(note)
 | 
			
		||||
        self.show_note(note)
 | 
			
		||||
        self.bring_to_foreground()
 | 
			
		||||
 | 
			
		||||
    def cleanup(self):
 | 
			
		||||
        gui_hooks.editor_did_load_note.remove(self.editor_did_load_note)
 | 
			
		||||
        gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
 | 
			
		||||
 | 
			
		||||
        self.editor.cleanup()
 | 
			
		||||
        saveGeom(self, self.dialog_geometry_tag)
 | 
			
		||||
        aqt.dialogs.markClosed(self.dialog_registry_tag)
 | 
			
		||||
 | 
			
		||||
    def closeEvent(self, evt: QCloseEvent) -> None:
 | 
			
		||||
        self.editor.call_after_note_saved(self.cleanup)
 | 
			
		||||
 | 
			
		||||
    # This method (mostly) solves (at least on my Windows 10 machine) three issues
 | 
			
		||||
    # with window activation. Without this not even too hacky a fix,
 | 
			
		||||
    #   * When dialog is opened from Yomichan *for the first time* since app start,
 | 
			
		||||
    #     the dialog opens in background (just like Browser does),
 | 
			
		||||
    #     but does not flash in taskbar (unlike Browser);
 | 
			
		||||
    #   * When dialog is opened, closed, *then main window is focused by clicking in it*,
 | 
			
		||||
    #     then dialog is opened from Yomichan again, same issue as above arises;
 | 
			
		||||
    #   * When dialog is restored from minimized state *and main window isn't minimized*,
 | 
			
		||||
    #     opening the dialog from Yomichan does not reliably focus it;
 | 
			
		||||
    #     sometimes it opens in foreground, sometimes in background.
 | 
			
		||||
    # With this fix, windows nearly always appear in foreground in all three cases.
 | 
			
		||||
    # In the case of the first two issues, strictly speaking, the fix is not ideal:
 | 
			
		||||
    # the window appears in background first, and then quickly pops into foreground.
 | 
			
		||||
    # It is not *too* unsightly, probably, no-one will notice this;
 | 
			
		||||
    # still, a better solution must be possible. TODO find one!
 | 
			
		||||
    #
 | 
			
		||||
    # Note that operation systems, notably Windows, and desktop managers, may restrict
 | 
			
		||||
    # background applications from raising windows to prevent them from interrupting
 | 
			
		||||
    # what the user is currently doing. For details, see:
 | 
			
		||||
    # https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
 | 
			
		||||
    # https://doc.qt.io/qt-5/qwidget.html#activateWindow
 | 
			
		||||
    # https://wiki.qt.io/Technical_FAQ#QWidget_::activateWindow.28.29_-_behavior_under_windows
 | 
			
		||||
    def bring_to_foreground(self):
 | 
			
		||||
        aqt.mw.app.processEvents()
 | 
			
		||||
        self.activateWindow()
 | 
			
		||||
        self.raise_()
 | 
			
		||||
 | 
			
		||||
    #################################### hooks enabled during dialog lifecycle
 | 
			
		||||
 | 
			
		||||
    def on_operation_did_execute(self, changes, handler):
 | 
			
		||||
        if changes.note_text and handler is not self.editor:
 | 
			
		||||
            self.reload_notes_after_user_action_elsewhere()
 | 
			
		||||
 | 
			
		||||
    def editor_did_load_note(self, _editor):
 | 
			
		||||
        self.enable_disable_next_and_previous_buttons()
 | 
			
		||||
 | 
			
		||||
    ###################################################### load & reload notes
 | 
			
		||||
 | 
			
		||||
    # setting editor.card is required for the "Cards…" button to work properly
 | 
			
		||||
    def show_note(self, note):
 | 
			
		||||
        self.note = note
 | 
			
		||||
        cards = note.cards()
 | 
			
		||||
 | 
			
		||||
        self.editor.set_note(note)
 | 
			
		||||
        self.editor.card = cards[0] if cards else None
 | 
			
		||||
 | 
			
		||||
        if any(is_card_suspended(card) for card in cards):
 | 
			
		||||
            tooltip("Some of the cards associated with this note " 
 | 
			
		||||
                    "have been suspended", parent=self)
 | 
			
		||||
 | 
			
		||||
    def reload_notes_after_user_action_elsewhere(self):
 | 
			
		||||
        history.remove_invalid_notes()
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            self.note.load()                    # this also updates the fields
 | 
			
		||||
        except NotFoundError:
 | 
			
		||||
            try:
 | 
			
		||||
                self.note = history.get_last_note()
 | 
			
		||||
            except IndexError:
 | 
			
		||||
                self.cleanup()
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
        self.show_note(self.note)
 | 
			
		||||
 | 
			
		||||
    ################################################################## actions
 | 
			
		||||
 | 
			
		||||
    # search two times, one is to select the current note or its cards,
 | 
			
		||||
    # and another to show the whole history, while keeping the above selection
 | 
			
		||||
    # set sort column to our search tag, which:
 | 
			
		||||
    #  * prevents the column sort indicator from being shown
 | 
			
		||||
    #  * serves as a hint for us to show notes or cards in history order
 | 
			
		||||
    #    (user can then click on any of the column names
 | 
			
		||||
    #    to show history cards in the order of their choosing)
 | 
			
		||||
    def show_browser(self, *_):
 | 
			
		||||
        def search_input_select_all(hook_browser, *_):
 | 
			
		||||
            hook_browser.form.searchEdit.lineEdit().selectAll()
 | 
			
		||||
            gui_hooks.browser_did_change_row.remove(search_input_select_all)
 | 
			
		||||
        gui_hooks.browser_did_change_row.append(search_input_select_all)
 | 
			
		||||
 | 
			
		||||
        browser = aqt.dialogs.open("Browser", aqt.mw)
 | 
			
		||||
        browser.table._state.sort_column = self.dialog_search_tag  # noqa
 | 
			
		||||
        browser.table._set_sort_indicator()  # noqa
 | 
			
		||||
 | 
			
		||||
        browser.search_for(f"nid:{self.note.id}")
 | 
			
		||||
        browser.table.select_all()
 | 
			
		||||
        browser.search_for(self.dialog_search_tag)
 | 
			
		||||
 | 
			
		||||
    def show_preview(self, *_):
 | 
			
		||||
        if cards := self.note.cards():
 | 
			
		||||
            previewer = DecentPreviewer(ReadyCardsAdapter(cards))
 | 
			
		||||
            previewer.open()
 | 
			
		||||
            return previewer
 | 
			
		||||
        else:
 | 
			
		||||
            tooltip("No cards found", parent=self)
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def show_previous(self, *_):
 | 
			
		||||
        if history.has_note_to_left_of(self.note):
 | 
			
		||||
            self.show_note(history.get_note_to_left_of(self.note))
 | 
			
		||||
 | 
			
		||||
    def show_next(self, *_):
 | 
			
		||||
        if history.has_note_to_right_of(self.note):
 | 
			
		||||
            self.show_note(history.get_note_to_right_of(self.note))
 | 
			
		||||
 | 
			
		||||
    ################################################## button and hotkey setup
 | 
			
		||||
 | 
			
		||||
    def setup_editor_buttons(self):
 | 
			
		||||
        gui_hooks.editor_did_init.append(self.add_preview_button)
 | 
			
		||||
        gui_hooks.editor_did_init_buttons.append(self.add_right_hand_side_buttons)
 | 
			
		||||
 | 
			
		||||
        # on Anki 2.1.50, browser mode makes the Preview button visible
 | 
			
		||||
        extra_kwargs = {} if anki_version < (2, 1, 50) else {
 | 
			
		||||
            "editor_mode": aqt.editor.EditorMode.BROWSER
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.editor = aqt.editor.Editor(aqt.mw, self.form.fieldsArea, self,
 | 
			
		||||
                                        **extra_kwargs)
 | 
			
		||||
 | 
			
		||||
        gui_hooks.editor_did_init_buttons.remove(self.add_right_hand_side_buttons)
 | 
			
		||||
        gui_hooks.editor_did_init.remove(self.add_preview_button)
 | 
			
		||||
 | 
			
		||||
    # * on Anki < 2.1.50, make the button via js (`setupEditor` of browser.py);
 | 
			
		||||
    #   also, make a copy of _links so that opening Anki's browser does not
 | 
			
		||||
    #   screw them up as they are apparently shared between instances?!
 | 
			
		||||
    #   the last part seems to have been fixed in Anki 2.1.50
 | 
			
		||||
    # * on Anki 2.1.50, the button is created by setting editor mode,
 | 
			
		||||
    #   see above; so we only need to add the link.
 | 
			
		||||
    def add_preview_button(self, editor):
 | 
			
		||||
        QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview)
 | 
			
		||||
 | 
			
		||||
        if anki_version < (2, 1, 50):
 | 
			
		||||
            editor._links = editor._links.copy()
 | 
			
		||||
            editor.web.eval("""
 | 
			
		||||
                $editorToolbar.then(({notetypeButtons}) => 
 | 
			
		||||
                    notetypeButtons.appendButton(
 | 
			
		||||
                        {component: editorToolbar.PreviewButton, id: 'preview'}
 | 
			
		||||
                    )
 | 
			
		||||
                );
 | 
			
		||||
            """)
 | 
			
		||||
 | 
			
		||||
        editor._links["preview"] = lambda _editor: self.show_preview() and None
 | 
			
		||||
 | 
			
		||||
    # * on Anki < 2.1.50, button style is okay-ish from get-go,
 | 
			
		||||
    #   except when disabled; adding class `btn` fixes that;
 | 
			
		||||
    # * on Anki 2.1.50, buttons have weird font size and are square';
 | 
			
		||||
    #   the style below makes them in line with left-hand side buttons
 | 
			
		||||
    def add_right_hand_side_buttons(self, buttons, editor):
 | 
			
		||||
        if anki_version < (2, 1, 50):
 | 
			
		||||
            extra_button_class = "btn"
 | 
			
		||||
        else:
 | 
			
		||||
            extra_button_class = "anki-connect-button"
 | 
			
		||||
            editor.web.eval("""
 | 
			
		||||
                (function(){
 | 
			
		||||
                    const style = document.createElement("style");
 | 
			
		||||
                    style.innerHTML = `
 | 
			
		||||
                        .anki-connect-button {
 | 
			
		||||
                            white-space: nowrap;
 | 
			
		||||
                            width: auto;
 | 
			
		||||
                            padding: 0 2px;
 | 
			
		||||
                            font-size: var(--base-font-size);
 | 
			
		||||
                        }
 | 
			
		||||
                        .anki-connect-button:disabled {
 | 
			
		||||
                            pointer-events: none;
 | 
			
		||||
                            opacity: .4;
 | 
			
		||||
                        }
 | 
			
		||||
                    `;
 | 
			
		||||
                    document.head.appendChild(style);
 | 
			
		||||
                })();
 | 
			
		||||
            """)
 | 
			
		||||
 | 
			
		||||
        def add(cmd, function, label, tip, keys):
 | 
			
		||||
            button_html = editor.addButton(
 | 
			
		||||
                icon=None, 
 | 
			
		||||
                cmd=DOMAIN_PREFIX + cmd, 
 | 
			
		||||
                id=DOMAIN_PREFIX + cmd,
 | 
			
		||||
                func=function, 
 | 
			
		||||
                label=f"  {label}  ",
 | 
			
		||||
                tip=f"{tip} ({keys})",
 | 
			
		||||
                keys=keys,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
            button_html = button_html.replace('class="',
 | 
			
		||||
                                              f'class="{extra_button_class} ')
 | 
			
		||||
            buttons.append(button_html)
 | 
			
		||||
 | 
			
		||||
        add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F")
 | 
			
		||||
        add("previous", self.show_previous, "<", "Previous", "Alt+Left")
 | 
			
		||||
        add("next", self.show_next, ">", "Next", "Alt+Right")
 | 
			
		||||
 | 
			
		||||
    def run_javascript_after_toolbar_ready(self, js):
 | 
			
		||||
        js = f"setTimeout(function() {{ {js} }}, 1)"
 | 
			
		||||
        if anki_version < (2, 1, 50):
 | 
			
		||||
            js = f'$editorToolbar.then(({{ toolbar }}) => {js})'
 | 
			
		||||
        else:
 | 
			
		||||
            js = f'require("anki/ui").loaded.then(() => {js})'
 | 
			
		||||
        self.editor.web.eval(js)
 | 
			
		||||
 | 
			
		||||
    def enable_disable_next_and_previous_buttons(self):
 | 
			
		||||
        def to_js(boolean):
 | 
			
		||||
            return "true" if boolean else "false"
 | 
			
		||||
 | 
			
		||||
        disable_previous = not(history.has_note_to_left_of(self.note))
 | 
			
		||||
        disable_next = not(history.has_note_to_right_of(self.note))
 | 
			
		||||
 | 
			
		||||
        self.run_javascript_after_toolbar_ready(f"""
 | 
			
		||||
            document.getElementById("{DOMAIN_PREFIX}previous")
 | 
			
		||||
                    .disabled = {to_js(disable_previous)};
 | 
			
		||||
            document.getElementById("{DOMAIN_PREFIX}next")
 | 
			
		||||
                    .disabled = {to_js(disable_next)};
 | 
			
		||||
        """)
 | 
			
		||||
 | 
			
		||||
    ##########################################################################
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def browser_will_search(cls, search_context):
 | 
			
		||||
        if search_context.search == cls.dialog_search_tag:
 | 
			
		||||
            trigger_search_for_dialog_history_notes(
 | 
			
		||||
                search_context=search_context,
 | 
			
		||||
                use_history_order=cls.dialog_search_tag ==
 | 
			
		||||
                        search_context.browser.table._state.sort_column  # noqa
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def register_with_anki(cls):
 | 
			
		||||
        if cls.dialog_registry_tag not in aqt.dialogs._dialogs:  # noqa
 | 
			
		||||
            aqt.dialogs.register_dialog(cls.dialog_registry_tag, cls)
 | 
			
		||||
            gui_hooks.browser_will_search.append(cls.browser_will_search)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def open_dialog_and_show_note_with_id(cls, note_id):    # raises NotFoundError
 | 
			
		||||
        note = get_note_by_note_id(note_id)
 | 
			
		||||
        return aqt.dialogs.open(cls.dialog_registry_tag, note)
 | 
			
		||||
							
								
								
									
										107
									
								
								plugin/util.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								plugin/util.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,107 @@
 | 
			
		||||
# Copyright 2016-2021 Alex Yatskov
 | 
			
		||||
#
 | 
			
		||||
# This program is free software: you can redistribute it and/or modify
 | 
			
		||||
# it under the terms of the GNU General Public License as published by
 | 
			
		||||
# the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
# (at your option) any later version.
 | 
			
		||||
#
 | 
			
		||||
# This program is distributed in the hope that it will be useful,
 | 
			
		||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 | 
			
		||||
# GNU General Public License for more details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU General Public License
 | 
			
		||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
import anki
 | 
			
		||||
import anki.sync
 | 
			
		||||
import aqt
 | 
			
		||||
import enum
 | 
			
		||||
import itertools
 | 
			
		||||
 | 
			
		||||
#
 | 
			
		||||
# Utilities
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class MediaType(enum.Enum):
 | 
			
		||||
    Audio = 1
 | 
			
		||||
    Video = 2
 | 
			
		||||
    Picture = 3
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def download(url):
 | 
			
		||||
    client = anki.sync.AnkiRequestsClient()
 | 
			
		||||
    client.timeout = setting('webTimeout') / 1000
 | 
			
		||||
 | 
			
		||||
    resp = client.get(url)
 | 
			
		||||
    if resp.status_code != 200:
 | 
			
		||||
        raise Exception('{} download failed with return code {}'.format(url, resp.status_code))
 | 
			
		||||
 | 
			
		||||
    return client.streamContent(resp)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def api(*versions):
 | 
			
		||||
    def decorator(func):
 | 
			
		||||
        setattr(func, 'versions', versions)
 | 
			
		||||
        setattr(func, 'api', True)
 | 
			
		||||
        return func
 | 
			
		||||
 | 
			
		||||
    return decorator
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cardQuestion(card):
 | 
			
		||||
    if getattr(card, 'question', None) is None:
 | 
			
		||||
        return card._getQA()['q']
 | 
			
		||||
 | 
			
		||||
    return card.question()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cardAnswer(card):
 | 
			
		||||
    if getattr(card, 'answer', None) is None:
 | 
			
		||||
        return card._getQA()['a']
 | 
			
		||||
 | 
			
		||||
    return card.answer()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
DEFAULT_CONFIG = {
 | 
			
		||||
    'apiKey': None,
 | 
			
		||||
    'apiLogPath': None,
 | 
			
		||||
    'apiPollInterval': 25,
 | 
			
		||||
    'apiVersion': 6,
 | 
			
		||||
    'webBacklog': 5,
 | 
			
		||||
    'webBindAddress': os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1'),
 | 
			
		||||
    'webBindPort': 8765,
 | 
			
		||||
    'webCorsOrigin': os.getenv('ANKICONNECT_CORS_ORIGIN', None),
 | 
			
		||||
    'webCorsOriginList': ['http://localhost'],
 | 
			
		||||
    'ignoreOriginList': [],
 | 
			
		||||
    'webTimeout': 10000,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
def setting(key):
 | 
			
		||||
    try:
 | 
			
		||||
        return aqt.mw.addonManager.getConfig(__name__).get(key, DEFAULT_CONFIG[key])
 | 
			
		||||
    except:
 | 
			
		||||
        raise Exception('setting {} not found'.format(key))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# see https://github.com/FooSoft/anki-connect/issues/308
 | 
			
		||||
# fixed in https://github.com/ankitects/anki/commit/0b2a226d
 | 
			
		||||
def patch_anki_2_1_50_having_null_stdout_on_windows():
 | 
			
		||||
    if sys.stdout is None:
 | 
			
		||||
        sys.stdout = open(os.devnull, "w", encoding="utf8")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# ref https://docs.python.org/3.12/library/itertools.html#itertools.batched
 | 
			
		||||
if sys.version_info >= (3, 12):
 | 
			
		||||
    batched = itertools.batched
 | 
			
		||||
else:
 | 
			
		||||
    def batched(iterable, n):
 | 
			
		||||
        iterator = iter(iterable)
 | 
			
		||||
        while True:
 | 
			
		||||
            batch = tuple(itertools.islice(iterator, n))
 | 
			
		||||
            if not batch:
 | 
			
		||||
                break
 | 
			
		||||
            yield batch
 | 
			
		||||
							
								
								
									
										301
									
								
								plugin/web.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										301
									
								
								plugin/web.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,301 @@
 | 
			
		||||
# Copyright 2016-2021 Alex Yatskov
 | 
			
		||||
#
 | 
			
		||||
# This program is free software: you can redistribute it and/or modify
 | 
			
		||||
# it under the terms of the GNU General Public License as published by
 | 
			
		||||
# the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
# (at your option) any later version.
 | 
			
		||||
#
 | 
			
		||||
# This program is distributed in the hope that it will be useful,
 | 
			
		||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 | 
			
		||||
# GNU General Public License for more details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU General Public License
 | 
			
		||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
import jsonschema
 | 
			
		||||
import select
 | 
			
		||||
import socket
 | 
			
		||||
 | 
			
		||||
from . import util
 | 
			
		||||
 | 
			
		||||
#
 | 
			
		||||
# WebRequest
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class WebRequest:
 | 
			
		||||
    def __init__(self, method, headers, body):
 | 
			
		||||
        self.method = method
 | 
			
		||||
        self.headers = headers
 | 
			
		||||
        self.body = body
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#
 | 
			
		||||
# WebClient
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class WebClient:
 | 
			
		||||
    def __init__(self, sock, handler):
 | 
			
		||||
        self.sock = sock
 | 
			
		||||
        self.handler = handler
 | 
			
		||||
        self.readBuff = bytes()
 | 
			
		||||
        self.writeBuff = bytes()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def advance(self, recvSize=1024):
 | 
			
		||||
        if self.sock is None:
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        rlist, wlist = select.select([self.sock], [self.sock], [], 0)[:2]
 | 
			
		||||
        self.sock.settimeout(5.0)
 | 
			
		||||
 | 
			
		||||
        if rlist:
 | 
			
		||||
            while True:
 | 
			
		||||
                try:
 | 
			
		||||
                    msg = self.sock.recv(recvSize)
 | 
			
		||||
                except (ConnectionResetError, socket.timeout):
 | 
			
		||||
                    self.close()
 | 
			
		||||
                    return False
 | 
			
		||||
                if not msg:
 | 
			
		||||
                    self.close()
 | 
			
		||||
                    return False
 | 
			
		||||
                self.readBuff += msg
 | 
			
		||||
 | 
			
		||||
                req, length = self.parseRequest(self.readBuff)
 | 
			
		||||
                if req is not None:
 | 
			
		||||
                    self.readBuff = self.readBuff[length:]
 | 
			
		||||
                    self.writeBuff += self.handler(req)
 | 
			
		||||
                    break
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        if wlist and self.writeBuff:
 | 
			
		||||
            try:
 | 
			
		||||
                length = self.sock.send(self.writeBuff)
 | 
			
		||||
                self.writeBuff = self.writeBuff[length:]
 | 
			
		||||
                if not self.writeBuff:
 | 
			
		||||
                    self.close()
 | 
			
		||||
                    return False
 | 
			
		||||
            except:
 | 
			
		||||
                self.close()
 | 
			
		||||
                return False
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def close(self):
 | 
			
		||||
        if self.sock is not None:
 | 
			
		||||
            self.sock.close()
 | 
			
		||||
            self.sock = None
 | 
			
		||||
 | 
			
		||||
        self.readBuff = bytes()
 | 
			
		||||
        self.writeBuff = bytes()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def parseRequest(self, data):
 | 
			
		||||
        parts = data.split('\r\n\r\n'.encode('utf-8'), 1)
 | 
			
		||||
        if len(parts) == 1:
 | 
			
		||||
            return None, 0
 | 
			
		||||
 | 
			
		||||
        lines = parts[0].split('\r\n'.encode('utf-8'))
 | 
			
		||||
        method = None
 | 
			
		||||
 | 
			
		||||
        if len(lines) > 0:
 | 
			
		||||
            request_line_parts = lines[0].split(' '.encode('utf-8'))
 | 
			
		||||
            method = request_line_parts[0].upper() if len(request_line_parts) > 0 else None
 | 
			
		||||
 | 
			
		||||
        headers = {}
 | 
			
		||||
        for line in lines[1:]:
 | 
			
		||||
            pair = line.split(': '.encode('utf-8'))
 | 
			
		||||
            headers[pair[0].lower()] = pair[1] if len(pair) > 1 else None
 | 
			
		||||
 | 
			
		||||
        headerLength = len(parts[0]) + 4
 | 
			
		||||
        bodyLength = int(headers.get('content-length'.encode('utf-8'), 0))
 | 
			
		||||
        totalLength = headerLength + bodyLength
 | 
			
		||||
 | 
			
		||||
        if totalLength > len(data):
 | 
			
		||||
            return None, 0
 | 
			
		||||
 | 
			
		||||
        body = data[headerLength : totalLength]
 | 
			
		||||
        return WebRequest(method, headers, body), totalLength
 | 
			
		||||
 | 
			
		||||
#
 | 
			
		||||
# WebServer
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class WebServer:
 | 
			
		||||
    def __init__(self, handler):
 | 
			
		||||
        self.handler = handler
 | 
			
		||||
        self.clients = []
 | 
			
		||||
        self.sock = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def advance(self):
 | 
			
		||||
        if self.sock is not None:
 | 
			
		||||
            self.acceptClients()
 | 
			
		||||
            self.advanceClients()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def acceptClients(self):
 | 
			
		||||
        rlist = select.select([self.sock], [], [], 0)[0]
 | 
			
		||||
        if not rlist:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        clientSock = self.sock.accept()[0]
 | 
			
		||||
        if clientSock is not None:
 | 
			
		||||
            clientSock.setblocking(False)
 | 
			
		||||
            self.clients.append(WebClient(clientSock, self.handlerWrapper))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def advanceClients(self):
 | 
			
		||||
        self.clients = list(filter(lambda c: c.advance(), self.clients))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def listen(self):
 | 
			
		||||
        self.close()
 | 
			
		||||
 | 
			
		||||
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 | 
			
		||||
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
 | 
			
		||||
        self.sock.setblocking(False)
 | 
			
		||||
        self.sock.bind((util.setting('webBindAddress'), util.setting('webBindPort')))
 | 
			
		||||
        self.sock.listen(util.setting('webBacklog'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def handlerWrapper(self, req):
 | 
			
		||||
        allowed, corsOrigin = self.allowOrigin(req)
 | 
			
		||||
 | 
			
		||||
        if req.method == b'OPTIONS':
 | 
			
		||||
            body = ''.encode('utf-8')
 | 
			
		||||
            headers = self.buildHeaders(corsOrigin, body)
 | 
			
		||||
 | 
			
		||||
            if b'access-control-request-private-network' in req.headers and (
 | 
			
		||||
            req.headers[b'access-control-request-private-network'] == b'true'):
 | 
			
		||||
                # include this header so that if a public origin is included in the whitelist,
 | 
			
		||||
                # then browsers won't fail requests due to the private network access check
 | 
			
		||||
                headers.append(['Access-Control-Allow-Private-Network', 'true'])
 | 
			
		||||
 | 
			
		||||
            return self.buildResponse(headers, body)
 | 
			
		||||
    
 | 
			
		||||
        try:
 | 
			
		||||
            params = json.loads(req.body.decode('utf-8'))
 | 
			
		||||
            jsonschema.validate(params, request_schema)
 | 
			
		||||
        except (ValueError, jsonschema.ValidationError) as e:
 | 
			
		||||
            if allowed:
 | 
			
		||||
                if len(req.body) == 0:
 | 
			
		||||
                    body = json.dumps({"apiVersion": f"AnkiConnect v.{util.setting('apiVersion')}"}).encode('utf-8')
 | 
			
		||||
                else:
 | 
			
		||||
                    reply = format_exception_reply(util.setting('apiVersion'), e)
 | 
			
		||||
                    body = json.dumps(reply).encode('utf-8')
 | 
			
		||||
                headers = self.buildHeaders(corsOrigin, body)
 | 
			
		||||
                return self.buildResponse(headers, body)
 | 
			
		||||
            else:
 | 
			
		||||
                params = {}  # trigger the 403 response below
 | 
			
		||||
 | 
			
		||||
        if allowed or params.get('action', '') == 'requestPermission':
 | 
			
		||||
            if params.get('action', '') == 'requestPermission':
 | 
			
		||||
                params['params'] = params.get('params', {})
 | 
			
		||||
                params['params']['allowed'] = allowed
 | 
			
		||||
                params['params']['origin'] = b'origin' in req.headers and req.headers[b'origin'].decode() or ''
 | 
			
		||||
                if not allowed :
 | 
			
		||||
                    corsOrigin = params['params']['origin']
 | 
			
		||||
                        
 | 
			
		||||
            body = json.dumps(self.handler(params)).encode('utf-8')
 | 
			
		||||
            headers = self.buildHeaders(corsOrigin, body)
 | 
			
		||||
        else :
 | 
			
		||||
            headers = [
 | 
			
		||||
                ['HTTP/1.1 403 Forbidden', None],
 | 
			
		||||
                ['Access-Control-Allow-Origin', corsOrigin],
 | 
			
		||||
                ['Access-Control-Allow-Headers', '*']
 | 
			
		||||
            ]
 | 
			
		||||
            body = ''.encode('utf-8')
 | 
			
		||||
 | 
			
		||||
        return self.buildResponse(headers, body)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def allowOrigin(self, req):
 | 
			
		||||
        # handle multiple cors origins by checking the 'origin'-header against the allowed origin list from the config
 | 
			
		||||
        webCorsOriginList = util.setting('webCorsOriginList')
 | 
			
		||||
 | 
			
		||||
        # keep support for deprecated 'webCorsOrigin' field, as long it is not removed
 | 
			
		||||
        webCorsOrigin = util.setting('webCorsOrigin')
 | 
			
		||||
        if webCorsOrigin:
 | 
			
		||||
            webCorsOriginList.append(webCorsOrigin)
 | 
			
		||||
 | 
			
		||||
        allowed = False
 | 
			
		||||
        corsOrigin = 'http://localhost'
 | 
			
		||||
        allowAllCors = '*' in webCorsOriginList  # allow CORS for all domains
 | 
			
		||||
        
 | 
			
		||||
        if allowAllCors:
 | 
			
		||||
            corsOrigin = '*'
 | 
			
		||||
            allowed = True
 | 
			
		||||
        elif b'origin' in req.headers:
 | 
			
		||||
            originStr = req.headers[b'origin'].decode()
 | 
			
		||||
            if originStr in webCorsOriginList :
 | 
			
		||||
                corsOrigin = originStr
 | 
			
		||||
                allowed = True
 | 
			
		||||
            elif 'http://localhost' in webCorsOriginList and ( 
 | 
			
		||||
            originStr == 'http://127.0.0.1' or originStr == 'https://127.0.0.1' or # allow 127.0.0.1 if localhost allowed
 | 
			
		||||
            originStr.startswith('http://127.0.0.1:') or originStr.startswith('http://127.0.0.1:') or
 | 
			
		||||
            originStr.startswith('chrome-extension://') or originStr.startswith('moz-extension://') or originStr.startswith('safari-web-extension://') ) : # allow chrome, firefox and safari extension if localhost allowed
 | 
			
		||||
                corsOrigin = originStr
 | 
			
		||||
                allowed = True
 | 
			
		||||
        else:
 | 
			
		||||
            allowed = True
 | 
			
		||||
        
 | 
			
		||||
        return allowed, corsOrigin
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    def buildHeaders(self, corsOrigin, body):
 | 
			
		||||
        return [
 | 
			
		||||
            ['HTTP/1.1 200 OK', None],
 | 
			
		||||
            ['Content-Type', 'application/json'],
 | 
			
		||||
            ['Access-Control-Allow-Origin', corsOrigin],
 | 
			
		||||
            ['Access-Control-Allow-Headers', '*'],
 | 
			
		||||
            ['Content-Length', str(len(body))]
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def buildResponse(self, headers, body):
 | 
			
		||||
        resp = bytes()
 | 
			
		||||
        for key, value in headers:
 | 
			
		||||
            if value is None:
 | 
			
		||||
                resp += '{}\r\n'.format(key).encode('utf-8')
 | 
			
		||||
            else:
 | 
			
		||||
                resp += '{}: {}\r\n'.format(key, value).encode('utf-8')
 | 
			
		||||
 | 
			
		||||
        resp += '\r\n'.encode('utf-8')
 | 
			
		||||
        resp += body
 | 
			
		||||
        return resp
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def close(self):
 | 
			
		||||
        if self.sock is not None:
 | 
			
		||||
            self.sock.close()
 | 
			
		||||
            self.sock = None
 | 
			
		||||
 | 
			
		||||
        for client in self.clients:
 | 
			
		||||
            client.close()
 | 
			
		||||
 | 
			
		||||
        self.clients = []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_success_reply(api_version, result):
 | 
			
		||||
    if api_version <= 4:
 | 
			
		||||
        return result
 | 
			
		||||
    else:
 | 
			
		||||
        return {"result": result, "error": None}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def format_exception_reply(_api_version, exception):
 | 
			
		||||
    return {"result": None, "error": str(exception)}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
request_schema = {
 | 
			
		||||
    "type": "object",
 | 
			
		||||
    "properties": {
 | 
			
		||||
        "action": {"type": "string", "minLength": 1},
 | 
			
		||||
        "version": {"type": "integer"},
 | 
			
		||||
        "params": {"type": "object"},
 | 
			
		||||
    },
 | 
			
		||||
    "required": ["action"],
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user