anki/qt/aqt/editor.py

1347 lines
44 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
from __future__ import annotations
2019-12-20 10:19:03 +01:00
import base64
import html
import itertools
2019-12-20 10:19:03 +01:00
import json
import mimetypes
2019-12-20 10:19:03 +01:00
import re
import urllib.error
import urllib.parse
import urllib.request
import warnings
2021-01-10 01:10:23 +01:00
from random import randrange
from typing import Any, Callable, Match, cast
import bs4
2019-12-20 10:19:03 +01:00
import requests
from bs4 import BeautifulSoup
import aqt
import aqt.sound
from anki._legacy import deprecated
2020-03-04 17:41:26 +01:00
from anki.cards import Card
from anki.collection import Config, SearchNode
from anki.consts import MODEL_CLOZE
2020-01-15 04:49:26 +01:00
from anki.hooks import runFilter
2020-01-19 02:33:27 +01:00
from anki.httpclient import HttpClient
2021-06-12 17:35:40 +02:00
from anki.notes import Note, NoteFieldsCheckResult
2021-01-07 07:20:02 +01:00
from anki.utils import checksum, isLin, isWin, namedtmp
from aqt import AnkiQt, colors, gui_hooks
from aqt.operations import QueryOp
2021-04-03 08:26:10 +02:00
from aqt.operations.note import update_note
from aqt.operations.notetype import update_notetype_legacy
from aqt.qt import *
from aqt.sound import av_player
2020-01-26 09:47:28 +01:00
from aqt.theme import theme_manager
2019-12-23 01:34:10 +01:00
from aqt.utils import (
HelpPage,
KeyboardModifiersPressed,
disable_help_button,
2019-12-23 01:34:10 +01:00
getFile,
openHelp,
qtMenuShortcutWorkaround,
restoreGeom,
saveGeom,
2019-12-23 01:34:10 +01:00
shortcut,
showInfo,
showWarning,
tooltip,
tr,
2019-12-23 01:34:10 +01:00
)
from aqt.webview import AnkiWebView
pics = ("jpg", "jpeg", "png", "tif", "tiff", "gif", "svg", "webp", "ico")
2019-12-23 01:34:10 +01:00
audio = (
"3gp",
"avi",
2019-12-23 01:34:10 +01:00
"flac",
"flv",
"m4a",
"mkv",
2019-12-23 01:34:10 +01:00
"mov",
"mp3",
"mp4",
2019-12-23 01:34:10 +01:00
"mpeg",
"mpg",
"oga",
"ogg",
"ogv",
"ogx",
"opus",
"spx",
"swf",
"wav",
"webm",
2019-12-23 01:34:10 +01:00
)
_html = """
<div id="fields"></div>
<div id="dupes" class="d-none">
<a href="#" onclick="pycmd('dupes');return false;">%s</a>
</div>
<div id="cloze-hint" class="d-none"></div>
<div id="tag-editor-anchor" class="d-none"></div>
"""
class Editor:
"""The screen that embeds an editing widget should listen for changes via
the `operation_did_execute` hook, and call set_note() when the editor needs
redrawing.
The editor will cause that hook to be fired when it saves changes. To avoid
an unwanted refresh, the parent widget should check if handler
corresponds to this editor instance, and ignore the change if it does.
"""
2021-02-01 08:28:35 +01:00
def __init__(
self, mw: AnkiQt, widget: QWidget, parentWindow: QWidget, addMode: bool = False
) -> None:
self.mw = mw
self.widget = widget
self.parentWindow = parentWindow
self.note: Note | None = None
self.addMode = addMode
self.currentField: int | None = None
# Similar to currentField, but not set to None on a blur. May be
# outside the bounds of the current notetype.
self.last_field_index: int | None = None
# current card, for card layout
self.card: Card | None = None
self.setupOuter()
self.setupWeb()
2017-10-11 20:51:26 +02:00
self.setupShortcuts()
gui_hooks.editor_did_init(self)
# Initial setup
############################################################
2021-02-01 08:28:35 +01:00
def setupOuter(self) -> None:
l = QVBoxLayout()
2019-12-23 01:34:10 +01:00
l.setContentsMargins(0, 0, 0, 0)
l.setSpacing(0)
self.widget.setLayout(l)
self.outerLayout = l
def setupWeb(self) -> None:
self.web = EditorWebView(self.widget, self)
self.web.allowDrops = True
self.web.set_bridge_command(self.onBridgeCmd, self)
self.outerLayout.addWidget(self.web, 1)
# then load page
2019-12-23 01:34:10 +01:00
self.web.stdHtml(
_html % tr.editing_show_duplicates(),
2021-02-27 16:34:22 +01:00
css=[
"css/editor.css",
],
js=[
"js/vendor/jquery.min.js",
"js/editor.js",
],
context=self,
default_css=True,
2019-12-23 01:34:10 +01:00
)
lefttopbtns: list[str] = []
gui_hooks.editor_did_init_left_buttons(lefttopbtns, self)
lefttopbtns_defs = [
f"$editorToolbar.then(({{ notetypeButtons }}) => notetypeButtons.appendButton({{ component: editorToolbar.Raw, props: {{ html: {json.dumps(button)} }} }}, -1));"
for button in lefttopbtns
]
lefttopbtns_js = "\n".join(lefttopbtns_defs)
righttopbtns: list[str] = []
gui_hooks.editor_did_init_buttons(righttopbtns, self)
# legacy filter
righttopbtns = runFilter("setupEditorButtons", righttopbtns, self)
righttopbtns_defs = ", ".join([json.dumps(button) for button in righttopbtns])
righttopbtns_js = (
f"""
2021-05-06 20:29:55 +02:00
$editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
component: editorToolbar.AddonButtons,
id: "addons",
props: {{ buttons: [ {righttopbtns_defs} ] }},
}}));
"""
2021-05-06 20:29:55 +02:00
if len(righttopbtns) > 0
else ""
)
2021-05-06 20:29:55 +02:00
self.web.eval(f"{lefttopbtns_js} {righttopbtns_js}")
# Top buttons
######################################################################
2021-02-01 08:28:35 +01:00
def resourceToData(self, path: str) -> str:
"""Convert a file (specified by a path) into a data URI."""
2017-01-14 21:16:50 +01:00
if not os.path.exists(path):
raise FileNotFoundError
mime, _ = mimetypes.guess_type(path)
2019-12-23 01:34:10 +01:00
with open(path, "rb") as fp:
data = fp.read()
2019-12-23 01:34:10 +01:00
data64 = b"".join(base64.encodebytes(data).splitlines())
return f"data:{mime};base64,{data64.decode('ascii')}"
2019-12-23 01:34:10 +01:00
def addButton(
self,
icon: str | None,
2019-12-23 01:34:10 +01:00
cmd: str,
func: Callable[[Editor], None],
2019-12-23 01:34:10 +01:00
tip: str = "",
label: str = "",
id: str = None,
toggleable: bool = False,
keys: str = None,
disables: bool = True,
rightside: bool = True,
2021-02-01 08:28:35 +01:00
) -> str:
"""Assign func to bridge cmd, register shortcut, return button"""
2020-04-26 17:01:23 +02:00
if func:
self._links[cmd] = func
if keys:
2021-01-10 01:10:23 +01:00
2021-02-01 08:28:35 +01:00
def on_activated() -> None:
func(self)
2021-01-10 01:10:23 +01:00
if toggleable:
# generate a random id for triggering toggle
id = id or str(randrange(1_000_000))
2021-02-01 08:28:35 +01:00
def on_hotkey() -> None:
on_activated()
self.web.eval(f'toggleEditorButton("#{id}");')
else:
on_hotkey = on_activated
QShortcut( # type: ignore
QKeySequence(keys),
self.widget,
activated=on_hotkey,
)
2021-01-10 01:10:23 +01:00
2019-12-23 01:34:10 +01:00
btn = self._addButton(
icon,
cmd,
tip=tip,
label=label,
id=id,
toggleable=toggleable,
disables=disables,
rightside=rightside,
2019-12-23 01:34:10 +01:00
)
return btn
2019-12-23 01:34:10 +01:00
def _addButton(
self,
icon: str | None,
2019-12-23 01:34:10 +01:00
cmd: str,
tip: str = "",
label: str = "",
id: str | None = None,
2019-12-23 01:34:10 +01:00
toggleable: bool = False,
disables: bool = True,
rightside: bool = True,
2020-08-11 22:56:58 +02:00
) -> str:
if icon:
if icon.startswith("qrc:/"):
iconstr = icon
elif os.path.isabs(icon):
iconstr = self.resourceToData(icon)
else:
iconstr = f"/_anki/imgs/{icon}.png"
imgelm = f"""<img class="topbut" src="{iconstr}">"""
else:
imgelm = ""
if label or not imgelm:
2021-02-27 17:22:55 +01:00
labelelm = label or cmd
else:
labelelm = ""
2017-01-06 16:40:10 +01:00
if id:
idstr = f"id={id}"
2017-01-06 16:40:10 +01:00
else:
idstr = ""
2017-01-08 14:33:45 +01:00
if toggleable:
2019-12-23 01:34:10 +01:00
toggleScript = "toggleEditorButton(this);"
2017-01-08 14:33:45 +01:00
else:
2019-12-23 01:34:10 +01:00
toggleScript = ""
tip = shortcut(tip)
if rightside:
class_ = "linkb"
else:
class_ = "rounded"
if not disables:
2020-10-04 22:51:34 +02:00
class_ += " perm"
return """<button tabindex=-1
{id}
2020-10-04 22:51:34 +02:00
class="{class_}"
type="button"
title="{tip}"
onclick="pycmd('{cmd}');{togglesc}return false;"
onmousedown="window.event.preventDefault();"
>
{imgelm}
{labelelm}
</button>""".format(
imgelm=imgelm,
cmd=cmd,
tip=tip,
labelelm=labelelm,
id=idstr,
togglesc=toggleScript,
2020-10-04 22:51:34 +02:00
class_=class_,
2019-12-23 01:34:10 +01:00
)
def setupShortcuts(self) -> None:
# if a third element is provided, enable shortcut even when no field selected
cuts: list[tuple] = []
gui_hooks.editor_did_init_shortcuts(cuts, self)
for row in cuts:
if len(row) == 2:
2019-12-23 02:31:42 +01:00
keys, fn = row # pylint: disable=unbalanced-tuple-unpacking
fn = self._addFocusCheck(fn)
else:
keys, fn, _ = row
QShortcut(QKeySequence(keys), self.widget, activated=fn) # type: ignore
2021-02-01 08:28:35 +01:00
def _addFocusCheck(self, fn: Callable) -> Callable:
def checkFocus() -> None:
if self.currentField is None:
return
fn()
2019-12-23 01:34:10 +01:00
return checkFocus
2021-02-01 08:28:35 +01:00
def onFields(self) -> None:
self.call_after_note_saved(self._onFields)
2021-02-01 08:28:35 +01:00
def _onFields(self) -> None:
from aqt.fields import FieldDialog
2019-12-23 01:34:10 +01:00
2021-06-27 04:12:23 +02:00
FieldDialog(self.mw, self.note.note_type(), parent=self.parentWindow)
2021-02-01 08:28:35 +01:00
def onCardLayout(self) -> None:
self.call_after_note_saved(self._onCardLayout)
2021-02-01 08:28:35 +01:00
def _onCardLayout(self) -> None:
from aqt.clayout import CardLayout
2019-12-23 01:34:10 +01:00
if self.card:
ord = self.card.ord
else:
ord = 0
2019-12-23 01:34:10 +01:00
CardLayout(
2020-05-14 12:58:45 +02:00
self.mw,
self.note,
ord=ord,
parent=self.parentWindow,
fill_empty=False,
2019-12-23 01:34:10 +01:00
)
if isWin:
self.parentWindow.activateWindow()
# JS->Python bridge
######################################################################
def onBridgeCmd(self, cmd: str) -> Any:
if not self.note:
# shutdown
return
# focus lost or key/button pressed?
if cmd.startswith("blur") or cmd.startswith("key"):
2021-02-01 08:28:35 +01:00
(type, ord_str, nid_str, txt) = cmd.split(":", 3)
ord = int(ord_str)
try:
2021-02-01 08:28:35 +01:00
nid = int(nid_str)
except ValueError:
nid = 0
if nid != self.note.id:
print("ignored late blur")
return
2020-08-09 10:38:31 +02:00
self.note.fields[ord] = self.mungeHTML(txt)
if not self.addMode:
self._save_current_note()
if type == "blur":
self.currentField = None
# run any filters
if gui_hooks.editor_did_unfocus_field(False, self.note, ord):
# something updated the note; update it after a subsequent focus
# event has had time to fire
self.mw.progress.timer(100, self.loadNoteKeepingFocus, False)
else:
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
self._check_and_update_duplicate_display_async()
else:
gui_hooks.editor_did_fire_typing_timer(self.note)
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
self._check_and_update_duplicate_display_async()
# focused into field?
elif cmd.startswith("focus"):
(type, num) = cmd.split(":", 1)
self.last_field_index = self.currentField = int(num)
gui_hooks.editor_did_focus_field(self.note, self.currentField)
elif cmd.startswith("toggleStickyAll"):
model = self.note.note_type()
flds = model["flds"]
any_sticky = any([fld["sticky"] for fld in flds])
result = []
for fld in flds:
if not any_sticky or fld["sticky"]:
fld["sticky"] = not fld["sticky"]
result.append(fld["sticky"])
update_notetype_legacy(parent=self.mw, notetype=model).run_in_background(
initiator=self
)
return result
elif cmd.startswith("toggleSticky"):
2021-02-28 01:17:42 +01:00
(type, num) = cmd.split(":", 1)
ord = int(num)
2021-06-27 04:12:23 +02:00
model = self.note.note_type()
fld = model["flds"][ord]
new_state = not fld["sticky"]
fld["sticky"] = new_state
update_notetype_legacy(parent=self.mw, notetype=model).run_in_background(
initiator=self
)
return new_state
2021-02-28 01:17:42 +01:00
elif cmd.startswith("lastTextColor"):
(_, textColor) = cmd.split(":", 1)
self.mw.pm.profile["lastTextColor"] = textColor
elif cmd.startswith("lastHighlightColor"):
(_, highlightColor) = cmd.split(":", 1)
self.mw.pm.profile["lastHighlightColor"] = highlightColor
2021-06-28 15:27:59 +02:00
elif cmd.startswith("saveTags"):
(type, tagsJson) = cmd.split(":", 1)
self.note.tags = json.loads(tagsJson)
gui_hooks.editor_did_update_tags(self.note)
if not self.addMode:
self._save_current_note()
elif cmd in self._links:
self._links[cmd](self)
else:
print("uncaught cmd", cmd)
2021-02-01 08:28:35 +01:00
def mungeHTML(self, txt: str) -> str:
return gui_hooks.editor_will_munge_html(txt, self)
# Setting/unsetting the current note
######################################################################
def set_note(
self, note: Note | None, hide: bool = True, focusTo: int | None = None
2021-02-01 08:28:35 +01:00
) -> None:
"Make NOTE the current note."
self.note = note
self.currentField = None
if self.note:
self.loadNote(focusTo=focusTo)
2021-06-28 15:45:29 +02:00
elif hide:
self.widget.hide()
2021-02-01 08:28:35 +01:00
def loadNoteKeepingFocus(self) -> None:
self.loadNote(self.currentField)
def loadNote(self, focusTo: int | None = None) -> None:
if not self.note:
return
data = [
(fld, self.mw.col.media.escape_media_filenames(val))
for fld, val in self.note.items()
]
self.widget.show()
2021-06-12 17:35:40 +02:00
note_fields_status = self.note.fields_check()
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
2021-02-01 08:28:35 +01:00
def oncallback(arg: Any) -> None:
if not self.note:
return
self.setupForegroundButton()
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
# we currently do this synchronously to ensure we load before the
# sidebar on browser startup
2021-06-12 17:35:40 +02:00
self._update_duplicate_display(note_fields_status)
if focusTo is not None:
self.web.setFocus()
gui_hooks.editor_did_load_note(self)
text_color = self.mw.pm.profile.get("lastTextColor", "#00f")
highlight_color = self.mw.pm.profile.get("lastHighlightColor", "#00f")
js = "setFields({}); setFonts({}); focusField({}); setNoteId({}); setColorButtons({}); setTags({}); ".format(
2019-12-23 01:34:10 +01:00
json.dumps(data),
json.dumps(self.fonts()),
json.dumps(focusTo),
json.dumps(self.note.id),
json.dumps([text_color, highlight_color]),
2021-06-28 14:58:15 +02:00
json.dumps(self.mw.col.tags.canonify(self.note.tags)),
2019-12-23 01:34:10 +01:00
)
2021-02-28 01:17:42 +01:00
if self.addMode:
2021-06-27 04:12:23 +02:00
sticky = [field["sticky"] for field in self.note.note_type()["flds"]]
2021-02-28 01:17:42 +01:00
js += " setSticky(%s);" % json.dumps(sticky)
js = gui_hooks.editor_will_load_note(js, self.note, self)
2019-12-22 23:44:43 +01:00
self.web.evalWithCallback(js, oncallback)
def _save_current_note(self) -> None:
"Call after note is updated with data from webview."
update_note(parent=self.widget, note=self.note).run_in_background(
initiator=self
)
def fonts(self) -> list[tuple[str, int, bool]]:
2019-12-23 01:34:10 +01:00
return [
(gui_hooks.editor_will_use_font_for_field(f["font"]), f["size"], f["rtl"])
2021-06-27 04:12:23 +02:00
for f in self.note.note_type()["flds"]
2019-12-23 01:34:10 +01:00
]
def call_after_note_saved(
self, callback: Callable, keepFocus: bool = False
) -> None:
"Save unsaved edits then call callback()."
if not self.note:
2019-12-23 01:34:10 +01:00
# calling code may not expect the callback to fire immediately
self.mw.progress.timer(10, callback, False)
return
self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())
saveNow = call_after_note_saved
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
def _check_and_update_duplicate_display_async(self) -> None:
note = self.note
if not note:
return
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
2021-06-12 17:35:40 +02:00
def on_done(result: NoteFieldsCheckResult.V) -> None:
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
if self.note != note:
return
self._update_duplicate_display(result)
QueryOp(
parent=self.parentWindow,
2021-06-12 17:35:40 +02:00
op=lambda _: note.fields_check(),
success=on_done,
).run_in_background()
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
checkValid = _check_and_update_duplicate_display_async
2021-06-12 17:35:40 +02:00
def _update_duplicate_display(self, result: NoteFieldsCheckResult.V) -> None:
cols = [""] * len(self.note.fields)
2021-06-14 10:21:42 +02:00
cloze_hint = ""
2021-06-12 17:35:40 +02:00
if result == NoteFieldsCheckResult.DUPLICATE:
cols[0] = "dupe"
2021-06-12 17:35:40 +02:00
elif result == NoteFieldsCheckResult.NOTETYPE_NOT_CLOZE:
2021-06-14 10:21:42 +02:00
cloze_hint = tr.adding_cloze_outside_cloze_notetype()
2021-06-12 17:35:40 +02:00
elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
2021-06-14 10:21:42 +02:00
cloze_hint = tr.adding_cloze_outside_cloze_field()
self.web.eval(f"setBackgrounds({json.dumps(cols)});")
2021-06-14 10:21:42 +02:00
self.web.eval(f"setClozeHint({json.dumps(cloze_hint)});")
2021-02-01 08:28:35 +01:00
def showDupes(self) -> None:
aqt.dialogs.open(
"Browser",
self.mw,
search=(
SearchNode(
dupe=SearchNode.Dupe(
2021-06-27 04:12:23 +02:00
notetype_id=self.note.note_type()["id"],
first_field=self.note.fields[0],
)
),
),
)
def fieldsAreBlank(self, previousNote: Note | None = None) -> bool:
if not self.note:
return True
2021-06-27 04:12:23 +02:00
m = self.note.note_type()
for c, f in enumerate(self.note.fields):
2020-03-24 11:54:19 +01:00
f = f.replace("<br>", "").strip()
notChangedvalues = {"", "<br>"}
2019-12-23 01:34:10 +01:00
if previousNote and m["flds"][c]["sticky"]:
2020-03-24 11:54:19 +01:00
notChangedvalues.add(previousNote.fields[c].replace("<br>", "").strip())
if f not in notChangedvalues:
return False
return True
2021-02-01 08:28:35 +01:00
def cleanup(self) -> None:
self.set_note(None)
# prevent any remaining evalWithCallback() events from firing after C++ object deleted
self.web = None
# legacy
setNote = set_note
# Tag handling
######################################################################
2021-02-01 08:28:35 +01:00
def setupTags(self) -> None:
import aqt.tagedit
2019-12-23 01:34:10 +01:00
g = QGroupBox(self.widget)
2020-01-26 09:47:28 +01:00
g.setStyleSheet("border: 0")
tb = QGridLayout()
tb.setSpacing(12)
2020-01-26 09:47:28 +01:00
tb.setContentsMargins(2, 6, 2, 6)
# tags
2021-03-26 04:48:26 +01:00
l = QLabel(tr.editing_tags())
tb.addWidget(l, 1, 0)
self.tags = aqt.tagedit.TagEdit(self.widget)
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
qconnect(self.tags.lostFocus, self.on_tag_focus_lost)
2021-03-26 04:48:26 +01:00
self.tags.setToolTip(shortcut(tr.editing_jump_to_tags_with_ctrlandshiftandt()))
border = theme_manager.color(colors.BORDER)
2020-01-26 09:47:28 +01:00
self.tags.setStyleSheet(f"border: 1px solid {border}")
tb.addWidget(self.tags, 1, 1)
g.setLayout(tb)
self.outerLayout.addWidget(g)
2021-02-01 08:28:35 +01:00
def updateTags(self) -> None:
if self.tags.col != self.mw.col:
self.tags.setCol(self.mw.col)
if not self.tags.text() or not self.addMode:
self.tags.setText(self.note.string_tags().strip())
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
def on_tag_focus_lost(self) -> None:
self.note.tags = self.mw.col.tags.split(self.tags.text())
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
gui_hooks.editor_did_update_tags(self.note)
if not self.addMode:
self._save_current_note()
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
def blur_tags_if_focused(self) -> None:
if not self.note:
return
if self.tags.hasFocus():
self.widget.setFocus()
2021-02-01 08:28:35 +01:00
def hideCompleters(self) -> None:
self.tags.hideCompleter()
2021-02-01 08:28:35 +01:00
def onFocusTags(self) -> None:
self.tags.setFocus()
Simplify note adding and the deck/notetype choosers The existing code was really difficult to reason about: - The default notetype depended on the selected deck, and vice versa, and this logic was buried in the deck and notetype choosing screens, and models.py. - Changes to the notetype were not passed back directly, but were fired via a hook, which changed any screen in the app that had a notetype selector. It also wasn't great for performance, as the most recent deck and tags were embedded in the notetype, which can be expensive to save and sync for large notetypes. To address these points: - The current deck for a notetype, and notetype for a deck, are now stored in separate config variables, instead of directly in the deck or notetype. These are cheap to read and write, and we'll be able to sync them individually in the future once config syncing is updated in the future. I seem to recall some users not wanting the tag saving behaviour, so I've dropped that for now, but if people end up missing it, it would be simple to add as an extra auxiliary config variable. - The logic for getting the starting deck and notetype has been moved into the backend. It should be the same as the older Python code, with one exception: when "change deck depending on notetype" is enabled in the preferences, it will start with the current notetype ("curModel"), instead of first trying to get a deck-specific notetype. - ModelChooser has been duplicated into notetypechooser.py, and it has been updated to solely be concerned with keeping track of a selected notetype - it no longer alters global state.
2021-03-08 14:23:24 +01:00
# legacy
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
Simplify note adding and the deck/notetype choosers The existing code was really difficult to reason about: - The default notetype depended on the selected deck, and vice versa, and this logic was buried in the deck and notetype choosing screens, and models.py. - Changes to the notetype were not passed back directly, but were fired via a hook, which changed any screen in the app that had a notetype selector. It also wasn't great for performance, as the most recent deck and tags were embedded in the notetype, which can be expensive to save and sync for large notetypes. To address these points: - The current deck for a notetype, and notetype for a deck, are now stored in separate config variables, instead of directly in the deck or notetype. These are cheap to read and write, and we'll be able to sync them individually in the future once config syncing is updated in the future. I seem to recall some users not wanting the tag saving behaviour, so I've dropped that for now, but if people end up missing it, it would be simple to add as an extra auxiliary config variable. - The logic for getting the starting deck and notetype has been moved into the backend. It should be the same as the older Python code, with one exception: when "change deck depending on notetype" is enabled in the preferences, it will start with the current notetype ("curModel"), instead of first trying to get a deck-specific notetype. - ModelChooser has been duplicated into notetypechooser.py, and it has been updated to solely be concerned with keeping track of a selected notetype - it no longer alters global state.
2021-03-08 14:23:24 +01:00
def saveAddModeVars(self) -> None:
pass
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
saveTags = blur_tags_if_focused
# Audio/video/images
######################################################################
2021-02-01 08:28:35 +01:00
def onAddMedia(self) -> None:
2019-12-23 01:34:10 +01:00
extension_filter = " ".join(
f"*.{extension}" for extension in sorted(itertools.chain(pics, audio))
2019-12-23 01:34:10 +01:00
)
2021-03-26 04:48:26 +01:00
filter = f"{tr.editing_media()} ({extension_filter})"
2019-12-23 01:34:10 +01:00
2021-02-01 08:28:35 +01:00
def accept(file: str) -> None:
self.addMedia(file)
2019-12-23 01:34:10 +01:00
file = getFile(
parent=self.widget,
2021-03-26 04:48:26 +01:00
title=tr.editing_add_media(),
cb=cast(Callable[[Any], None], accept),
filter=filter,
key="media",
)
self.parentWindow.activateWindow()
2021-02-01 08:28:35 +01:00
def addMedia(self, path: str, canDelete: bool = False) -> None:
"""canDelete is a legacy arg and is ignored."""
try:
html = self._addMedia(path)
except Exception as e:
showWarning(str(e))
return
self.web.eval(f"setFormat('inserthtml', {json.dumps(html)});")
2021-02-01 08:28:35 +01:00
def _addMedia(self, path: str, canDelete: bool = False) -> str:
"""Add to media folder and return local img or sound tag."""
# copy to media folder
fname = self.mw.col.media.addFile(path)
# return a local html link
return self.fnameToLink(fname)
def _addMediaFromData(self, fname: str, data: bytes) -> str:
return self.mw.col.media.writeData(fname, data)
2021-02-01 08:28:35 +01:00
def onRecSound(self) -> None:
aqt.sound.record_audio(
self.parentWindow,
self.mw,
True,
lambda file: self.addMedia(file, canDelete=True),
)
# Media downloads
######################################################################
def urlToLink(self, url: str) -> str | None:
fname = self.urlToFile(url)
if not fname:
return None
return self.fnameToLink(fname)
def fnameToLink(self, fname: str) -> str:
ext = fname.split(".")[-1].lower()
if ext in pics:
name = urllib.parse.quote(fname.encode("utf8"))
return f'<img src="{name}">'
else:
av_player.play_file(fname)
return f"[sound:{html.escape(fname, quote=False)}]"
def urlToFile(self, url: str) -> str | None:
l = url.lower()
2019-12-23 01:34:10 +01:00
for suffix in pics + audio:
if l.endswith(f".{suffix}"):
return self._retrieveURL(url)
# not a supported type
return None
2021-02-01 08:28:35 +01:00
def isURL(self, s: str) -> bool:
2013-07-18 13:32:41 +02:00
s = s.lower()
2019-12-23 01:34:10 +01:00
return (
s.startswith("http://")
2013-07-18 13:32:41 +02:00
or s.startswith("https://")
or s.startswith("ftp://")
2019-12-23 01:34:10 +01:00
or s.startswith("file://")
)
2013-07-18 13:32:41 +02:00
def inlinedImageToFilename(self, txt: str) -> str:
prefix = "data:image/"
suffix = ";base64,"
for ext in ("jpg", "jpeg", "png", "gif"):
fullPrefix = prefix + ext + suffix
if txt.startswith(fullPrefix):
2019-12-23 01:34:10 +01:00
b64data = txt[len(fullPrefix) :].strip()
data = base64.b64decode(b64data, validate=True)
if ext == "jpeg":
ext = "jpg"
return self._addPastedImage(data, f".{ext}")
return ""
def inlinedImageToLink(self, src: str) -> str:
fname = self.inlinedImageToFilename(src)
if fname:
return self.fnameToLink(fname)
return ""
# ext should include dot
def _addPastedImage(self, data: bytes, ext: str) -> str:
# hash and write
csum = checksum(data)
fname = f"paste-{csum}{ext}"
return self._addMediaFromData(fname, data)
def _retrieveURL(self, url: str) -> str | None:
"Download file into media folder and return local filename or None."
# urllib doesn't understand percent-escaped utf8, but requires things like
# '#' to be escaped.
url = urllib.parse.unquote(url)
if url.lower().startswith("file://"):
url = url.replace("%", "%25")
url = url.replace("#", "%23")
local = True
else:
local = False
# fetch it into a temporary folder
2019-12-23 01:34:10 +01:00
self.mw.progress.start(immediate=not local, parent=self.parentWindow)
2020-05-21 01:22:34 +02:00
content_type = None
error_msg: str | None = None
try:
if local:
2019-12-23 01:34:10 +01:00
req = urllib.request.Request(
url, None, {"User-Agent": "Mozilla/5.0 (compatible; Anki)"}
)
2020-05-21 02:57:49 +02:00
with urllib.request.urlopen(req) as response:
filecontents = response.read()
else:
2020-05-21 02:57:49 +02:00
with HttpClient() as client:
client.timeout = 30
with client.get(url) as response:
if response.status_code != 200:
error_msg = tr.qt_misc_unexpected_response_code(
val=response.status_code,
2020-05-21 02:57:49 +02:00
)
return None
2020-05-21 02:57:49 +02:00
filecontents = response.content
content_type = response.headers.get("content-type")
except (urllib.error.URLError, requests.exceptions.RequestException) as e:
error_msg = tr.editing_an_error_occurred_while_opening(val=str(e))
return None
finally:
self.mw.progress.finish()
if error_msg:
showWarning(error_msg)
# strip off any query string
2017-12-13 05:34:54 +01:00
url = re.sub(r"\?.*?$", "", url)
2020-01-28 12:45:26 +01:00
fname = os.path.basename(urllib.parse.unquote(url))
if not fname.strip():
fname = "paste"
2020-05-21 01:22:34 +02:00
if content_type:
fname = self.mw.col.media.add_extension_based_on_mime(fname, content_type)
2020-01-28 12:45:26 +01:00
return self.mw.col.media.write_data(fname, filecontents)
# Paste/drag&drop
######################################################################
removeTags = ["script", "iframe", "object", "style"]
def _pastePreFilter(self, html: str, internal: bool) -> str:
# https://anki.tenderapp.com/discussions/ankidesktop/39543-anki-is-replacing-the-character-by-when-i-exit-the-html-edit-mode-ctrlshiftx
if html.find(">") < 0:
return html
with warnings.catch_warnings() as w:
2019-12-23 01:34:10 +01:00
warnings.simplefilter("ignore", UserWarning)
doc = BeautifulSoup(html, "html.parser")
tag: bs4.element.Tag
if not internal:
for tag in self.removeTags:
for node in doc(tag):
node.decompose()
# convert p tags to divs
for node in doc("p"):
node.name = "div"
for tag in doc("img"):
try:
2019-12-23 01:34:10 +01:00
src = tag["src"]
except KeyError:
# for some bizarre reason, mnemosyne removes src elements
# from missing media
continue
# in internal pastes, rewrite mediasrv references to relative
if internal:
2017-12-13 05:34:54 +01:00
m = re.match(r"http://127.0.0.1:\d+/(.*)$", src)
if m:
2019-12-23 01:34:10 +01:00
tag["src"] = m.group(1)
else:
# in external pastes, download remote media
if self.isURL(src):
fname = self._retrieveURL(src)
if fname:
2019-12-23 01:34:10 +01:00
tag["src"] = fname
elif src.startswith("data:image/"):
# and convert inlined data
2019-12-23 01:34:10 +01:00
tag["src"] = self.inlinedImageToFilename(src)
html = str(doc)
return html
def doPaste(self, html: str, internal: bool, extended: bool = False) -> None:
html = self._pastePreFilter(html, internal)
if extended:
ext = "true"
else:
ext = "false"
self.web.eval(f"pasteHTML({json.dumps(html)}, {json.dumps(internal)}, {ext});")
gui_hooks.editor_did_paste(self, html, internal, extended)
2021-09-05 13:54:04 +02:00
def doDrop(
self, html: str, internal: bool, extended: bool, cursor_pos: QPoint
) -> None:
2021-02-01 08:28:35 +01:00
def pasteIfField(ret: bool) -> None:
if ret:
self.doPaste(html, internal, extended)
2021-09-05 13:54:04 +02:00
self.web.evalWithCallback(
f"focusIfField({cursor_pos.x()}, {cursor_pos.y()});", pasteIfField
)
2021-02-01 08:28:35 +01:00
def onPaste(self) -> None:
self.web.onPaste()
2021-02-01 08:28:35 +01:00
def onCutOrCopy(self) -> None:
self.web.flagAnkiText()
# Legacy editing routines
######################################################################
_js_legacy = "this routine has been moved into JS, and will be removed soon"
@deprecated(info=_js_legacy)
def onHtmlEdit(self) -> None:
field = self.currentField
self.call_after_note_saved(lambda: self._onHtmlEdit(field))
@deprecated(info=_js_legacy)
def _onHtmlEdit(self, field: int) -> None:
d = QDialog(self.widget, Qt.WindowType.Window)
form = aqt.forms.edithtml.Ui_Dialog()
form.setupUi(d)
restoreGeom(d, "htmlEditor")
disable_help_button(d)
qconnect(
form.buttonBox.helpRequested, lambda: openHelp(HelpPage.EDITING_FEATURES)
)
font = QFont("Courier")
font.setStyleHint(QFont.StyleHint.TypeWriter)
form.textEdit.setFont(font)
form.textEdit.setPlainText(self.note.fields[field])
d.show()
form.textEdit.moveCursor(QTextCursor.MoveOperation.End)
d.exec()
html = form.textEdit.toPlainText()
if html.find(">") > -1:
# filter html through beautifulsoup so we can strip out things like a
# leading </div>
html_escaped = self.mw.col.media.escape_media_filenames(html)
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
html_escaped = str(BeautifulSoup(html_escaped, "html.parser"))
html = self.mw.col.media.escape_media_filenames(
html_escaped, unescape=True
)
self.note.fields[field] = html
if not self.addMode:
self._save_current_note()
self.loadNote(focusTo=field)
saveGeom(d, "htmlEditor")
@deprecated(info=_js_legacy)
def toggleBold(self) -> None:
self.web.eval("setFormat('bold');")
@deprecated(info=_js_legacy)
def toggleItalic(self) -> None:
self.web.eval("setFormat('italic');")
@deprecated(info=_js_legacy)
def toggleUnderline(self) -> None:
self.web.eval("setFormat('underline');")
@deprecated(info=_js_legacy)
def toggleSuper(self) -> None:
self.web.eval("setFormat('superscript');")
@deprecated(info=_js_legacy)
def toggleSub(self) -> None:
self.web.eval("setFormat('subscript');")
@deprecated(info=_js_legacy)
def removeFormat(self) -> None:
self.web.eval("setFormat('removeFormat');")
@deprecated(info=_js_legacy)
def onCloze(self) -> None:
self.call_after_note_saved(self._onCloze, keepFocus=True)
@deprecated(info=_js_legacy)
def _onCloze(self) -> None:
# check that the model is set up for cloze deletion
if self.note.note_type()["type"] != MODEL_CLOZE:
if self.addMode:
tooltip(tr.editing_warning_cloze_deletions_will_not_work())
else:
showInfo(tr.editing_to_make_a_cloze_deletion_on())
return
# find the highest existing cloze
highest = 0
for name, val in list(self.note.items()):
m = re.findall(r"\{\{c(\d+)::", val)
if m:
highest = max(highest, sorted(int(x) for x in m)[-1])
# reuse last?
if not KeyboardModifiersPressed().alt:
highest += 1
# must start at 1
highest = max(1, highest)
self.web.eval("wrap('{{c%d::', '}}');" % highest)
def setupForegroundButton(self) -> None:
self.fcolour = self.mw.pm.profile.get("lastColour", "#00f")
# use last colour
@deprecated(info=_js_legacy)
def onForeground(self) -> None:
self._wrapWithColour(self.fcolour)
# choose new colour
@deprecated(info=_js_legacy)
def onChangeCol(self) -> None:
if isLin:
new = QColorDialog.getColor(
QColor(self.fcolour),
None,
None,
QColorDialog.ColorDialogOption.DontUseNativeDialog,
)
else:
new = QColorDialog.getColor(QColor(self.fcolour), None)
# native dialog doesn't refocus us for some reason
self.parentWindow.activateWindow()
if new.isValid():
self.fcolour = new.name()
self.onColourChanged()
self._wrapWithColour(self.fcolour)
@deprecated(info=_js_legacy)
def _updateForegroundButton(self) -> None:
pass
@deprecated(info=_js_legacy)
def onColourChanged(self) -> None:
self._updateForegroundButton()
self.mw.pm.profile["lastColour"] = self.fcolour
@deprecated(info=_js_legacy)
def _wrapWithColour(self, colour: str) -> None:
self.web.eval(f"setFormat('forecolor', '{colour}')")
@deprecated(info=_js_legacy)
2021-02-01 08:28:35 +01:00
def onAdvanced(self) -> None:
m = QMenu(self.mw)
2019-12-22 13:56:17 +01:00
for text, handler, shortcut in (
2021-03-26 04:48:26 +01:00
(tr.editing_mathjax_inline(), self.insertMathjaxInline, "Ctrl+M, M"),
(tr.editing_mathjax_block(), self.insertMathjaxBlock, "Ctrl+M, E"),
(
2021-03-26 04:48:26 +01:00
tr.editing_mathjax_chemistry(),
self.insertMathjaxChemistry,
"Ctrl+M, C",
),
2021-03-26 04:48:26 +01:00
(tr.editing_latex(), self.insertLatex, "Ctrl+T, T"),
(tr.editing_latex_equation(), self.insertLatexEqn, "Ctrl+T, E"),
(tr.editing_latex_math_env(), self.insertLatexMathEnv, "Ctrl+T, M"),
(tr.editing_edit_html(), self.onHtmlEdit, "Ctrl+Shift+X"),
2019-12-22 13:56:17 +01:00
):
a = m.addAction(text)
qconnect(a.triggered, handler)
2019-12-22 13:56:17 +01:00
a.setShortcut(QKeySequence(shortcut))
qtMenuShortcutWorkaround(m)
m.exec(QCursor.pos())
@deprecated(info=_js_legacy)
2021-02-01 08:28:35 +01:00
def insertLatex(self) -> None:
self.web.eval("wrap('[latex]', '[/latex]');")
@deprecated(info=_js_legacy)
2021-02-01 08:28:35 +01:00
def insertLatexEqn(self) -> None:
self.web.eval("wrap('[$]', '[/$]');")
@deprecated(info=_js_legacy)
2021-02-01 08:28:35 +01:00
def insertLatexMathEnv(self) -> None:
self.web.eval("wrap('[$$]', '[/$$]');")
@deprecated(info=_js_legacy)
2021-02-01 08:28:35 +01:00
def insertMathjaxInline(self) -> None:
self.web.eval("wrap('\\\\(', '\\\\)');")
@deprecated(info=_js_legacy)
2021-02-01 08:28:35 +01:00
def insertMathjaxBlock(self) -> None:
self.web.eval("wrap('\\\\[', '\\\\]');")
@deprecated(info=_js_legacy)
2021-02-01 08:28:35 +01:00
def insertMathjaxChemistry(self) -> None:
self.web.eval("wrap('\\\\(\\\\ce{', '}\\\\)');")
2018-08-06 05:17:57 +02:00
# Links from HTML
######################################################################
_links: dict[str, Callable] = dict(
fields=onFields,
cards=onCardLayout,
bold=toggleBold,
italic=toggleItalic,
underline=toggleUnderline,
super=toggleSuper,
sub=toggleSub,
clear=removeFormat,
colour=onForeground,
changeCol=onChangeCol,
cloze=onCloze,
attach=onAddMedia,
record=onRecSound,
more=onAdvanced,
dupes=showDupes,
paste=onPaste,
cutOrCopy=onCutOrCopy,
htmlEdit=onHtmlEdit,
2021-04-01 01:38:50 +02:00
mathjaxInline=insertMathjaxInline,
mathjaxBlock=insertMathjaxBlock,
mathjaxChemistry=insertMathjaxChemistry,
)
2019-12-23 01:34:10 +01:00
# Pasting, drag & drop, and keyboard layouts
######################################################################
2019-12-23 01:34:10 +01:00
class EditorWebView(AnkiWebView):
2021-02-01 08:28:35 +01:00
def __init__(self, parent: QWidget, editor: Editor) -> None:
AnkiWebView.__init__(self, title="editor")
self.editor = editor
self.setAcceptDrops(True)
self._markInternal = False
clip = self.editor.mw.app.clipboard()
qconnect(clip.dataChanged, self._onClipboardChange)
gui_hooks.editor_web_view_did_init(self)
2021-02-01 08:28:35 +01:00
def _onClipboardChange(self) -> None:
if self._markInternal:
self._markInternal = False
self._flagAnkiText()
2021-02-01 08:28:35 +01:00
def onCut(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Cut)
2021-02-01 08:28:35 +01:00
def onCopy(self) -> None:
self.triggerPageAction(QWebEnginePage.WebAction.Copy)
2020-09-14 16:07:31 +02:00
def _wantsExtendedPaste(self) -> bool:
strip_html = self.editor.mw.col.get_config_bool(
Config.Bool.PASTE_STRIPS_FORMATTING
)
if KeyboardModifiersPressed().shift:
strip_html = not strip_html
return not strip_html
2020-09-14 16:07:31 +02:00
def _onPaste(self, mode: QClipboard.Mode) -> None:
extended = self._wantsExtendedPaste()
2018-03-02 02:16:02 +01:00
mime = self.editor.mw.app.clipboard().mimeData(mode=mode)
html, internal = self._processMime(mime, extended)
if not html:
return
self.editor.doPaste(html, internal, extended)
def onPaste(self) -> None:
self._onPaste(QClipboard.Mode.Clipboard)
2018-03-02 02:16:02 +01:00
def onMiddleClickPaste(self) -> None:
self._onPaste(QClipboard.Mode.Selection)
2018-03-02 02:16:02 +01:00
2021-02-01 08:28:35 +01:00
def dragEnterEvent(self, evt: QDragEnterEvent) -> None:
evt.accept()
2021-02-01 08:28:35 +01:00
def dropEvent(self, evt: QDropEvent) -> None:
extended = self._wantsExtendedPaste()
mime = evt.mimeData()
cursor_pos = self.mapFromGlobal(QCursor.pos())
if evt.source() and mime.hasHtml():
# don't filter html from other fields
html, internal = mime.html(), True
else:
html, internal = self._processMime(mime, extended, drop_event=True)
if not html:
return
self.editor.doDrop(html, internal, extended, cursor_pos)
# returns (html, isInternal)
2021-09-05 13:54:04 +02:00
def _processMime(
self, mime: QMimeData, extended: bool = False, drop_event: bool = False
) -> tuple[str, bool]:
# print("html=%s image=%s urls=%s txt=%s" % (
# mime.hasHtml(), mime.hasImage(), mime.hasUrls(), mime.hasText()))
# print("html", mime.html())
# print("urls", mime.urls())
# print("text", mime.text())
internal = mime.html().startswith("<!--anki-->")
2021-09-05 13:54:04 +02:00
mime = gui_hooks.editor_will_process_mime(
mime, self, internal, extended, drop_event
)
# try various content types in turn
if mime.hasHtml():
2021-09-05 15:10:34 +02:00
html_content = mime.html()[11:] if internal else mime.html()
return html_content, internal
# favour url if it's a local link
if mime.hasUrls() and mime.urls()[0].toString().startswith("file://"):
types = (self._processUrls, self._processImage, self._processText)
else:
types = (self._processImage, self._processUrls, self._processText)
for fn in types:
html = fn(mime, extended)
if html:
return html, True
return "", False
def _processUrls(self, mime: QMimeData, extended: bool = False) -> str | None:
if not mime.hasUrls():
return None
buf = ""
for qurl in mime.urls():
url = qurl.toString()
# chrome likes to give us the URL twice with a \n
url = url.splitlines()[0]
buf += self.editor.urlToLink(url) or ""
return buf
def _processText(self, mime: QMimeData, extended: bool = False) -> str | None:
if not mime.hasText():
return None
txt = mime.text()
2020-07-24 07:12:46 +02:00
processed = []
2020-07-24 04:51:36 +02:00
lines = txt.split("\n")
for line in lines:
2020-07-24 08:18:05 +02:00
for token in re.split(r"(\S+)", line):
2020-07-24 04:51:36 +02:00
# inlined data in base64?
if extended and token.startswith("data:image/"):
2020-07-24 07:12:46 +02:00
processed.append(self.editor.inlinedImageToLink(token))
elif extended and self.editor.isURL(token):
2020-07-24 04:51:36 +02:00
# if the user is pasting an image or sound link, convert it to local
link = self.editor.urlToLink(token)
if link:
2020-07-24 07:12:46 +02:00
processed.append(link)
2020-07-24 04:51:36 +02:00
else:
# not media; add it as a normal link
2020-10-11 15:09:56 +02:00
link = '<a href="{}">{}</a>'.format(
token, html.escape(urllib.parse.unquote(token))
)
2020-07-24 07:12:46 +02:00
processed.append(link)
2020-07-24 04:51:36 +02:00
else:
token = html.escape(token).replace("\t", " " * 4)
# if there's more than one consecutive space,
# use non-breaking spaces for the second one on
def repl(match: Match) -> str:
return f"{match.group(1).replace(' ', '&nbsp;')} "
2020-07-24 08:00:34 +02:00
2020-07-24 04:51:36 +02:00
token = re.sub(" ( +)", repl, token)
2020-07-24 07:12:46 +02:00
processed.append(token)
2020-07-24 04:51:36 +02:00
2020-07-24 07:12:46 +02:00
processed.append("<br>")
# remove last <br>
processed.pop()
return "".join(processed)
def _processImage(self, mime: QMimeData, extended: bool = False) -> str | None:
if not mime.hasImage():
return None
im = QImage(mime.imageData())
uname = namedtmp("paste")
if self.editor.mw.col.get_config_bool(Config.Bool.PASTE_IMAGES_AS_PNG):
ext = ".png"
2019-12-23 01:34:10 +01:00
im.save(uname + ext, None, 50)
else:
ext = ".jpg"
2019-12-23 01:34:10 +01:00
im.save(uname + ext, None, 80)
# invalid image?
2019-12-23 01:34:10 +01:00
path = uname + ext
if not os.path.exists(path):
return None
with open(path, "rb") as file:
data = file.read()
2018-08-08 04:45:59 +02:00
fname = self.editor._addPastedImage(data, ext)
if fname:
return self.editor.fnameToLink(fname)
return None
2021-02-01 08:28:35 +01:00
def flagAnkiText(self) -> None:
# be ready to adjust when clipboard event fires
self._markInternal = True
2021-02-01 08:28:35 +01:00
def _flagAnkiText(self) -> None:
# add a comment in the clipboard html so we can tell text is copied
# from us and doesn't need to be stripped
clip = self.editor.mw.app.clipboard()
if not isMac and not clip.ownsClipboard():
return
mime = clip.mimeData()
if not mime.hasHtml():
return
html = mime.html()
mime.setHtml(f"<!--anki-->{html}")
clip.setMimeData(mime)
def contextMenuEvent(self, evt: QContextMenuEvent) -> None:
m = QMenu(self)
2021-03-26 04:48:26 +01:00
a = m.addAction(tr.editing_cut())
qconnect(a.triggered, self.onCut)
2021-03-26 04:48:26 +01:00
a = m.addAction(tr.actions_copy())
qconnect(a.triggered, self.onCopy)
2021-03-26 04:48:26 +01:00
a = m.addAction(tr.editing_paste())
qconnect(a.triggered, self.onPaste)
gui_hooks.editor_will_show_context_menu(self, m)
m.popup(QCursor.pos())
2019-12-23 01:34:10 +01:00
2018-11-15 05:04:08 +01:00
# QFont returns "Kozuka Gothic Pro L" but WebEngine expects "Kozuka Gothic Pro Light"
# - there may be other cases like a trailing 'Bold' that need fixing, but will
# wait for further reports first.
2021-02-01 08:28:35 +01:00
def fontMungeHack(font: str) -> str:
2018-11-15 05:04:08 +01:00
return re.sub(" L$", " Light", font)
2019-12-23 01:34:10 +01:00
2021-02-01 08:28:35 +01:00
def munge_html(txt: str, editor: Editor) -> str:
return "" if txt in ("<br>", "<div><br></div>") else txt
2020-08-09 11:16:19 +02:00
2021-02-01 08:28:35 +01:00
def remove_null_bytes(txt: str, editor: Editor) -> str:
# misbehaving apps may include a null byte in the text
return txt.replace("\x00", "")
2020-08-09 11:16:19 +02:00
def reverse_url_quoting(txt: str, editor: Editor) -> str:
# reverse the url quoting we added to get images to display
return editor.mw.col.media.escape_media_filenames(txt, unescape=True)
gui_hooks.editor_will_use_font_for_field.append(fontMungeHack)
gui_hooks.editor_will_munge_html.append(munge_html)
gui_hooks.editor_will_munge_html.append(remove_null_bytes)
gui_hooks.editor_will_munge_html.append(reverse_url_quoting)
def set_cloze_button(editor: Editor) -> None:
2021-06-27 04:12:23 +02:00
if editor.note.note_type()["type"] == MODEL_CLOZE:
editor.web.eval(
'$editorToolbar.then(({ templateButtons }) => templateButtons.showButton("cloze")); '
)
else:
editor.web.eval(
'$editorToolbar.then(({ templateButtons }) => templateButtons.hideButton("cloze")); '
)
gui_hooks.editor_did_load_note.append(set_cloze_button)