anki/qt/aqt/editor.py

1319 lines
43 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
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, Dict, List, Match, Optional, Tuple, cast
import bs4
2019-12-20 10:19:03 +01:00
import requests
from bs4 import BeautifulSoup
import aqt
import aqt.sound
2020-03-04 17:41:26 +01:00
from anki.cards import Card
from anki.collection import 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
from anki.notes import Note
2021-01-07 07:20:02 +01:00
from anki.utils import checksum, isLin, isWin, namedtmp
from aqt import AnkiQt, colors, gui_hooks
2020-08-16 18:49:51 +02:00
from aqt.main import ResetReason
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 (
TR,
HelpPage,
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 = """
<style>
:root {
--bg-color: %s;
}
</style>
<div>
<div id="topbutsOuter">
%s
</div>
<div id="fields">
</div>
<div id="dupes" class="is-inactive">
<a href="#" onclick="pycmd('dupes');return false;">%s</a>
</div>
</div>
"""
# caller is responsible for resetting note on reset
class Editor:
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: Optional[Note] = None
self.addMode = addMode
self.currentField: Optional[int] = None
# current card, for card layout
2020-03-04 17:41:26 +01:00
self.card: Optional[Card] = None
self.setupOuter()
self.setupWeb()
2017-10-11 20:51:26 +02:00
self.setupShortcuts()
self.setupTags()
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)
lefttopbtns: List[str] = [
self._addButton(
None,
"fields",
tr(TR.EDITING_CUSTOMIZE_FIELDS),
f"{tr(TR.EDITING_FIELDS)}...",
disables=False,
rightside=False,
),
self._addButton(
None,
"cards",
tr(TR.EDITING_CUSTOMIZE_CARD_TEMPLATES_CTRLANDL),
f"{tr(TR.EDITING_CARDS)}...",
disables=False,
rightside=False,
),
]
gui_hooks.editor_did_init_left_buttons(lefttopbtns, self)
2019-12-22 13:56:17 +01:00
righttopbtns: List[str] = [
2019-12-23 01:34:10 +01:00
self._addButton(
"text_bold", "bold", tr(TR.EDITING_BOLD_TEXT_CTRLANDB), id="bold"
2019-12-23 01:34:10 +01:00
),
self._addButton(
"text_italic",
"italic",
tr(TR.EDITING_ITALIC_TEXT_CTRLANDI),
id="italic",
2019-12-23 01:34:10 +01:00
),
self._addButton(
"text_under",
"underline",
tr(TR.EDITING_UNDERLINE_TEXT_CTRLANDU),
id="underline",
),
self._addButton(
"text_super",
"super",
tr(TR.EDITING_SUPERSCRIPT_CTRLANDAND),
id="superscript",
),
self._addButton(
"text_sub", "sub", tr(TR.EDITING_SUBSCRIPT_CTRLAND), id="subscript"
),
self._addButton(
"text_clear", "clear", tr(TR.EDITING_REMOVE_FORMATTING_CTRLANDR)
2019-12-23 01:34:10 +01:00
),
self._addButton(
None,
"colour",
tr(TR.EDITING_SET_FOREGROUND_COLOUR_F7),
"""
<span id="forecolor" class="topbut rounded" style="background: #000"></span>
2021-02-28 01:17:42 +01:00
""",
),
self._addButton(
None,
"changeCol",
tr(TR.EDITING_CHANGE_COLOUR_F8),
"""
<span class="topbut rounded rainbow"></span>
2021-02-28 01:17:42 +01:00
""",
),
self._addButton(
"text_cloze", "cloze", tr(TR.EDITING_CLOZE_DELETION_CTRLANDSHIFTANDC)
),
self._addButton(
"paperclip", "attach", tr(TR.EDITING_ATTACH_PICTURESAUDIOVIDEO_F3)
),
self._addButton("media-record", "record", tr(TR.EDITING_RECORD_AUDIO_F5)),
self._addButton("more", "more"),
2019-12-22 13:56:17 +01:00
]
gui_hooks.editor_did_init_buttons(righttopbtns, self)
# legacy filter
2017-01-06 16:37:57 +01:00
righttopbtns = runFilter("setupEditorButtons", righttopbtns, self)
topbuts = """
<div id="topbutsleft" class="topbuts">
%(leftbts)s
</div>
<div id="topbutsright" class="topbuts">
%(rightbts)s
</div>
2019-12-23 01:34:10 +01:00
""" % dict(
leftbts="".join(lefttopbtns),
2019-12-23 01:34:10 +01:00
rightbts="".join(righttopbtns),
)
bgcol = self.mw.app.palette().window().color().name() # type: ignore
# then load page
2019-12-23 01:34:10 +01:00
self.web.stdHtml(
_html % (bgcol, topbuts, tr(TR.EDITING_SHOW_DUPLICATES)),
2021-02-27 16:34:22 +01:00
css=[
"css/vendor/bootstrap.min.css",
"css/vendor/bootstrap-icons.css",
2021-02-27 16:34:22 +01:00
"css/editor.css",
],
js=[
"js/vendor/jquery.min.js",
"js/vendor/bootstrap.bundle.min.js",
"js/editor.js",
],
context=self,
2019-12-23 01:34:10 +01:00
)
self.web.eval("preventButtonFocus();")
# 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: Optional[str],
2019-12-23 01:34:10 +01:00
cmd: str,
func: Callable[["Editor"], None],
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: Optional[str],
2019-12-23 01:34:10 +01:00
cmd: str,
tip: str = "",
label: str = "",
id: Optional[str] = None,
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;"
>
{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] = [
("Ctrl+L", self.onCardLayout, True),
("Ctrl+B", self.toggleBold),
("Ctrl+I", self.toggleItalic),
("Ctrl+U", self.toggleUnderline),
("Ctrl++", self.toggleSuper),
("Ctrl+=", self.toggleSub),
("Ctrl+R", self.removeFormat),
("F7", self.onForeground),
("F8", self.onChangeCol),
("Ctrl+Shift+C", self.onCloze),
("Ctrl+Shift+Alt+C", self.onCloze),
("F3", self.onAddMedia),
("F5", self.onRecSound),
("Ctrl+T, T", self.insertLatex),
("Ctrl+T, E", self.insertLatexEqn),
("Ctrl+T, M", self.insertLatexMathEnv),
("Ctrl+M, M", self.insertMathjaxInline),
("Ctrl+M, E", self.insertMathjaxBlock),
2018-08-06 05:17:57 +02:00
("Ctrl+M, C", self.insertMathjaxChemistry),
("Ctrl+Shift+X", self.onHtmlEdit),
2019-12-23 01:34:10 +01:00
("Ctrl+Shift+T", self.onFocusTags, True),
]
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.saveNow(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
FieldDialog(self.mw, self.note.model(), parent=self.parentWindow)
2021-02-01 08:28:35 +01:00
def onCardLayout(self) -> None:
self.saveNow(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=self.addMode,
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()
2020-08-16 18:49:51 +02:00
self.mw.requireReset(reason=ResetReason.EditorBridgeCmd, context=self)
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:
self.checkValid()
else:
gui_hooks.editor_did_fire_typing_timer(self.note)
self.checkValid()
# focused into field?
elif cmd.startswith("focus"):
(type, num) = cmd.split(":", 1)
self.currentField = int(num)
gui_hooks.editor_did_focus_field(self.note, self.currentField)
elif cmd.startswith("toggleSticky"):
2021-02-28 01:17:42 +01:00
(type, num) = cmd.split(":", 1)
ord = int(num)
fld = self.note.model()["flds"][ord]
new_state = not fld["sticky"]
fld["sticky"] = new_state
return new_state
2021-02-28 01:17:42 +01:00
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
######################################################################
2021-02-01 08:28:35 +01:00
def setNote(
self, note: Optional[Note], hide: bool = True, focusTo: Optional[int] = None
) -> None:
"Make NOTE the current note."
self.note = note
self.currentField = None
if self.note:
self.loadNote(focusTo=focusTo)
else:
self.hideCompleters()
if hide:
self.widget.hide()
2021-02-01 08:28:35 +01:00
def loadNoteKeepingFocus(self) -> None:
self.loadNote(self.currentField)
2021-02-01 08:28:35 +01:00
def loadNote(self, focusTo: Optional[int] = None) -> None:
if not self.note:
return
2019-12-23 01:34:10 +01:00
data = [
(fld, self.mw.col.media.escape_media_filenames(val))
for fld, val in self.note.items()
2019-12-23 01:34:10 +01:00
]
self.widget.show()
self.updateTags()
2021-02-01 08:28:35 +01:00
def oncallback(arg: Any) -> None:
if not self.note:
return
self.setupForegroundButton()
self.checkValid()
if focusTo is not None:
self.web.setFocus()
gui_hooks.editor_did_load_note(self)
2021-01-29 19:48:17 +01:00
js = "setFields(%s); setFonts(%s); focusField(%s); setNoteId(%s);" % (
2019-12-23 01:34:10 +01:00
json.dumps(data),
json.dumps(self.fonts()),
json.dumps(focusTo),
json.dumps(self.note.id),
)
2021-02-28 01:17:42 +01:00
if self.addMode:
sticky = [field["sticky"] for field in self.note.model()["flds"]]
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."
self.mw.col.update_note(self.note)
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"])
2019-12-23 01:34:10 +01:00
for f in self.note.model()["flds"]
]
2021-02-01 08:28:35 +01:00
def saveNow(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.saveTags()
self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())
2021-02-01 08:28:35 +01:00
def checkValid(self) -> None:
cols = [""] * len(self.note.fields)
err = self.note.dupeOrEmpty()
if err == 2:
cols[0] = "dupe"
self.web.eval(f"setBackgrounds({json.dumps(cols)});")
2021-02-01 08:28:35 +01:00
def showDupes(self) -> None:
aqt.dialogs.open(
"Browser",
self.mw,
search=(
SearchNode(
dupe=SearchNode.Dupe(
notetype_id=self.note.model()["id"],
first_field=self.note.fields[0],
)
),
),
)
2021-02-01 08:28:35 +01:00
def fieldsAreBlank(self, previousNote: Optional[Note] = None) -> bool:
if not self.note:
return True
m = self.note.model()
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.setNote(None)
# prevent any remaining evalWithCallback() events from firing after C++ object deleted
self.web = None
# HTML editing
######################################################################
2021-02-01 08:28:35 +01:00
def onHtmlEdit(self) -> None:
field = self.currentField
self.saveNow(lambda: self._onHtmlEdit(field))
2021-02-01 08:28:35 +01:00
def _onHtmlEdit(self, field: int) -> None:
d = QDialog(self.widget, Qt.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.TypeWriter)
form.textEdit.setFont(font)
form.textEdit.setPlainText(self.note.fields[field])
d.show()
form.textEdit.moveCursor(QTextCursor.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
2020-05-20 06:59:22 +02:00
if not self.addMode:
self._save_current_note()
self.loadNote(focusTo=field)
saveGeom(d, "htmlEditor")
# 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
l = QLabel(tr(TR.EDITING_TAGS))
tb.addWidget(l, 1, 0)
self.tags = aqt.tagedit.TagEdit(self.widget)
qconnect(self.tags.lostFocus, self.saveTags)
self.tags.setToolTip(
shortcut(tr(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.stringTags().strip())
def saveTags(self) -> None:
if not self.note:
return
self.note.tags = self.mw.col.tags.split(self.tags.text())
if not self.addMode:
self._save_current_note()
gui_hooks.editor_did_update_tags(self.note)
2021-02-01 08:28:35 +01:00
def saveAddModeVars(self) -> None:
if self.addMode:
# save tags to model
m = self.note.model()
2019-12-23 01:34:10 +01:00
m["tags"] = self.note.tags
self.mw.col.models.save(m, updateReqs=False)
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()
# Format buttons
######################################################################
2021-02-01 08:28:35 +01:00
def toggleBold(self) -> None:
self.web.eval("setFormat('bold');")
2021-02-01 08:28:35 +01:00
def toggleItalic(self) -> None:
self.web.eval("setFormat('italic');")
2021-02-01 08:28:35 +01:00
def toggleUnderline(self) -> None:
self.web.eval("setFormat('underline');")
2021-02-01 08:28:35 +01:00
def toggleSuper(self) -> None:
self.web.eval("setFormat('superscript');")
2021-02-01 08:28:35 +01:00
def toggleSub(self) -> None:
self.web.eval("setFormat('subscript');")
2021-02-01 08:28:35 +01:00
def removeFormat(self) -> None:
self.web.eval("setFormat('removeFormat');")
2021-02-01 08:28:35 +01:00
def onCloze(self) -> None:
self.saveNow(self._onCloze, keepFocus=True)
2021-02-01 08:28:35 +01:00
def _onCloze(self) -> None:
# check that the model is set up for cloze deletion
if self.note.model()["type"] != MODEL_CLOZE:
if self.addMode:
2020-11-18 02:32:22 +01:00
tooltip(tr(TR.EDITING_WARNING_CLOZE_DELETIONS_WILL_NOT_WORK))
else:
2020-11-18 02:32:22 +01:00
showInfo(tr(TR.EDITING_TO_MAKE_A_CLOZE_DELETION_ON))
return
# find the highest existing cloze
highest = 0
for name, val in list(self.note.items()):
2017-12-13 05:34:54 +01:00
m = re.findall(r"\{\{c(\d+)::", val)
if m:
highest = max(highest, sorted([int(x) for x in m])[-1])
# reuse last?
if not self.mw.app.keyboardModifiers() & Qt.AltModifier:
highest += 1
# must start at 1
highest = max(1, highest)
self.web.eval("wrap('{{c%d::', '}}');" % highest)
# Foreground colour
######################################################################
2021-02-01 08:28:35 +01:00
def setupForegroundButton(self) -> None:
self.fcolour = self.mw.pm.profile.get("lastColour", "#00f")
self.onColourChanged()
# use last colour
2021-02-01 08:28:35 +01:00
def onForeground(self) -> None:
self._wrapWithColour(self.fcolour)
# choose new colour
2021-02-01 08:28:35 +01:00
def onChangeCol(self) -> None:
if isLin:
new = QColorDialog.getColor(
QColor(self.fcolour), None, None, QColorDialog.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)
2021-02-01 08:28:35 +01:00
def _updateForegroundButton(self) -> None:
self.web.eval(f"setFGButton('{self.fcolour}')")
2021-02-01 08:28:35 +01:00
def onColourChanged(self) -> None:
self._updateForegroundButton()
2019-12-23 01:34:10 +01:00
self.mw.pm.profile["lastColour"] = self.fcolour
2021-02-01 08:28:35 +01:00
def _wrapWithColour(self, colour: str) -> None:
self.web.eval(f"setFormat('forecolor', '{colour}')")
# 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
)
filter = f"{tr(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, canDelete=True)
2019-12-23 01:34:10 +01:00
file = getFile(
parent=self.widget,
title=tr(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:
try:
html = self._addMedia(path, canDelete)
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)
# remove original?
2019-12-23 01:34:10 +01:00
if canDelete and self.mw.pm.profile["deleteMedia"]:
if os.path.abspath(fname) != os.path.abspath(path):
try:
os.unlink(path)
except:
pass
# 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) -> Optional[str]:
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) -> Optional[str]:
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) -> Optional[str]:
"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: Optional[str] = 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(
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(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});")
def doDrop(self, html: str, internal: bool, extended: bool = False) -> None:
2021-02-01 08:28:35 +01:00
def pasteIfField(ret: bool) -> None:
if ret:
self.doPaste(html, internal, extended)
p = self.web.mapFromGlobal(QCursor.pos())
self.web.evalWithCallback(f"focusIfField({p.x()}, {p.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()
# Advanced menu
######################################################################
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 (
(tr(TR.EDITING_MATHJAX_INLINE), self.insertMathjaxInline, "Ctrl+M, M"),
(tr(TR.EDITING_MATHJAX_BLOCK), self.insertMathjaxBlock, "Ctrl+M, E"),
(
tr(TR.EDITING_MATHJAX_CHEMISTRY),
self.insertMathjaxChemistry,
"Ctrl+M, C",
),
(tr(TR.EDITING_LATEX), self.insertLatex, "Ctrl+T, T"),
(tr(TR.EDITING_LATEX_EQUATION), self.insertLatexEqn, "Ctrl+T, E"),
(tr(TR.EDITING_LATEX_MATH_ENV), self.insertLatexMathEnv, "Ctrl+T, M"),
(tr(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())
# LaTeX
######################################################################
2021-02-01 08:28:35 +01:00
def insertLatex(self) -> None:
self.web.eval("wrap('[latex]', '[/latex]');")
2021-02-01 08:28:35 +01:00
def insertLatexEqn(self) -> None:
self.web.eval("wrap('[$]', '[/$]');")
2021-02-01 08:28:35 +01:00
def insertLatexMathEnv(self) -> None:
self.web.eval("wrap('[$$]', '[/$$]');")
2021-02-01 08:28:35 +01:00
def insertMathjaxInline(self) -> None:
self.web.eval("wrap('\\\\(', '\\\\)');")
2021-02-01 08:28:35 +01:00
def insertMathjaxBlock(self) -> None:
self.web.eval("wrap('\\\\[', '\\\\]');")
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
######################################################################
2021-02-01 08:28:35 +01:00
_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,
)
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
2019-12-23 01:34:10 +01:00
self.strip = self.editor.mw.pm.profile["stripHTML"]
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.Cut)
2021-02-01 08:28:35 +01:00
def onCopy(self) -> None:
self.triggerPageAction(QWebEnginePage.Copy)
2020-09-14 16:07:31 +02:00
def _wantsExtendedPaste(self) -> bool:
2019-12-06 04:37:50 +01:00
extended = not (self.editor.mw.app.queryKeyboardModifiers() & Qt.ShiftModifier)
if self.editor.mw.pm.profile.get("pasteInvert", False):
extended = not extended
2020-09-14 16:07:31 +02:00
return extended
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:
2018-03-02 02:16:02 +01:00
self._onPaste(QClipboard.Clipboard)
def onMiddleClickPaste(self) -> None:
2018-03-02 02:16:02 +01:00
self._onPaste(QClipboard.Selection)
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()
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)
if not html:
return
self.editor.doDrop(html, internal, extended)
# returns (html, isInternal)
def _processMime(self, mime: QMimeData, extended: 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())
# try various content types in turn
html, internal = self._processHtml(mime)
if html:
return html, 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) -> Optional[str]:
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) -> Optional[str]:
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 _processHtml(self, mime: QMimeData) -> Tuple[Optional[str], bool]:
if not mime.hasHtml():
return None, False
html = mime.html()
# no filtering required for internal pastes
if html.startswith("<!--anki-->"):
return html[11:], True
return html, False
def _processImage(self, mime: QMimeData, extended: bool = False) -> Optional[str]:
if not mime.hasImage():
return None
im = QImage(mime.imageData())
uname = namedtmp("paste")
if self.editor.mw.pm.profile.get("pastePNG", False):
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)
a = m.addAction(tr(TR.EDITING_CUT))
qconnect(a.triggered, self.onCut)
a = m.addAction(tr(TR.ACTIONS_COPY))
qconnect(a.triggered, self.onCopy)
a = m.addAction(tr(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
2021-02-01 08:28:35 +01: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)
2020-08-09 11:16:19 +02:00
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)