new add-on handling

- separate dialog for managing add-ons
- only add-ons compatible with Anki 2.1 will be shown on AnkiWeb
- can delete or toggle disabled on multiple add-ons at once
- check for updates button
- button to view add-on's AnkiWeb page

The new handling drops support for single file .py add-ons, and requires
add-ons to store all files in a single folder. This ensures all files
are cleaned up properly when updating or deleting an add-on, and
prevents file conflicts between separate add-ons. See the updated
add-on docs for more:

https://apps.ankiweb.net/docs/addons21.html#add-on-folders
https://apps.ankiweb.net/docs/addons21.html#sharing-add-ons

README.addons has been moved to the above page
This commit is contained in:
Damien Elmes 2017-08-26 15:14:20 +10:00
parent f824db2143
commit 7288a9b063
7 changed files with 388 additions and 239 deletions

View File

@ -1,98 +0,0 @@
Porting add-ons to Anki 2.1
---------------------------
Python 3
---------
Anki 2.1 requires Python 3.5 or later. After installing Python 3 on your
machine, you can use the 2to3 tool to automatically convert your existing
scripts to Python 3 code on a folder by folder basis, like:
2to3-3.5 --output-dir=aqt3 -W -n aqt
mv aqt aqt-old
mv aqt3 aqt
Most simple code can be converted automatically, but there may be parts of the
code that you need to manually modify.
Qt5 / PyQt5
------------
The syntax for connecting signals and slots has changed in PyQt5. Recent PyQt4
versions support the new syntax as well, so after updating your add-ons you
may find they still work in Anki 2.0.x as well.
More info is available at
http://pyqt.sourceforge.net/Docs/PyQt4/new_style_signals_slots.html
One add-on author reported that the following tool was useful to automatically
convert the code:
https://github.com/rferrazz/pyqt4topyqt5
Compatibility with Anki 2.0
----------------------------
It should be possible for many add-ons to support both Anki 2.0 and 2.1 at the
same time.
Most Python 3 code will run on Python 2 as well, though extra work may be
required when dealing with file access and byte strings.
The Qt modules are in 'PyQt5' instead of 'PyQt4'. You can do a conditional
import, but an easier way is to import from aqt.qt - eg "from aqt.qt import *"
The most difficult part is the change from the unsupported QtWebKit to
QtWebEngine. If you do any non-trivial work with webviews, some work will be
required to port your code to Anki 2.1, as described in the next section.
If you find that non-trivial changes are required to get your add-on working
with Anki 2.1, the easiest option is to drop support for older Anki versions.
Please see the 'sharing updated add-ons' section below for more information.
Webview changes
----------------
Qt 5 has dropped WebKit in favour of the Chromium-based WebEngine, so
Anki's webviews are now using WebEngine. Of note:
- You can now debug the webviews using an external Chrome instance, by setting
the env var QTWEBENGINE_REMOTE_DEBUGGING to 8080 prior to starting Anki,
then surfing to localhost:8080 in Chrome.
- WebEngine uses a different method of communicating back to Python.
AnkiWebView() is a wrapper for webviews which provides a pycmd(str) function in
Javascript which will call the ankiwebview's onBridgeCmd(str) method. Various
parts of Anki's UI like reviewer.py and deckbrowser.py have had to be
modified to use this.
- Javascript is evaluated asynchronously, so if you need the result of a JS
expression you can use ankiwebview's evalWithCallback().
- As a result of this asynchronous behaviour, editor.saveNow() now requires a
callback. If your add-on performs actions in the browser, you likely need to
call editor.saveNow() first and then run the rest of your code in the callback.
Calls to .onSearch() will need to be changed to .search()/.onSearchActivated()
as well. See the browser's .deleteNotes() for an example.
- Various operations that were supported by WebKit like setScrollPosition() now
need to be implemented in javascript.
- Page actions like mw.web.triggerPageAction(QWebEnginePage.Copy) are also
asynchronous, and need to be rewritten to use javascript or a delay.
- WebEngine doesn't provide a keyPressEvent() like WebKit did, so the code
that catches shortcuts not attached to a menu or button has had to be changed.
See the way reviewer.py calls setStateShortcuts() for an example.
Add-ons without a top level file
---------------------------------
Add-ons no longer require a top level file - if you just distribute a single
folder, the folder's __init__.py file will form the entry point. This will not
work in 2.0.x however.
Sharing updated add-ons
------------------------
If you've succeeded in making an add-on that supports both 2.0.x and 2.1.x at
the same time, please feel free to upload it to the shared add-ons area, and
mention in the description that it works with both versions.
If you've decided to make a separate 2.1.x version, it's probably best to just
post a link to it in your current add-on description or upload it separately.
When we get closer to a release I'll look into adding separate uploads for the
two versions.

View File

@ -5,9 +5,9 @@ For info on contributing things other than code, such as translations, decks
and add-ons, please see http://ankisrs.net/docs/manual.html#contributing and add-ons, please see http://ankisrs.net/docs/manual.html#contributing
The goal of Anki 2.1.x is to bring Anki up to date with Python 3 and Qt 5, The goal of Anki 2.1.x is to bring Anki up to date with Python 3 and Qt 5,
while changing as little else as possible. Modern Linux distros have started while maintaining compatibility with Anki 2.0.x. Some users will be stuck on
dropping support for Qt 4, so we need to keep changes to a minimum in order to Anki 2.0 for a while due to unported add-ons or old hardware, so it's
get an update out faster. important that 2.1 doesn't make breaking changes to the file format.
Also of consideration is that the Anki code is indirectly used by the mobile Also of consideration is that the Anki code is indirectly used by the mobile
clients, which try their best to keep as close to the Anki code as possible so clients, which try their best to keep as close to the Anki code as possible so

View File

@ -2,9 +2,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import io import io
import sys, os, traceback import json
from io import StringIO import re
import zipfile import zipfile
from send2trash import send2trash
from aqt.qt import * from aqt.qt import *
from aqt.utils import showInfo, openFolder, isWin, openLink, \ from aqt.utils import showInfo, openFolder, isWin, openLink, \
askUser, restoreGeom, saveGeom, showWarning, tooltip askUser, restoreGeom, saveGeom, showWarning, tooltip
@ -13,131 +15,103 @@ import aqt.forms
import aqt import aqt
from aqt.downloader import download from aqt.downloader import download
from anki.lang import _ from anki.lang import _
from anki.utils import intTime
# in the future, it would be nice to save the addon id and unzippped file list from anki.sync import AnkiRequestsClient
# to the config so that we can clear up all files and check for updates
class AddonManager: class AddonManager:
def __init__(self, mw): def __init__(self, mw):
self.mw = mw self.mw = mw
self.dirty = False
f = self.mw.form f = self.mw.form
f.actionOpenPluginFolder.triggered.connect(self.onOpenAddonFolder) f.actionAdd_ons.triggered.connect(self.onAddonsDialog)
f.actionDownloadSharedPlugin.triggered.connect(self.onGetAddons)
self._menus = []
if isWin:
self.clearAddonCache()
sys.path.insert(0, self.addonsFolder()) sys.path.insert(0, self.addonsFolder())
if not self.mw.safeMode: if not self.mw.safeMode:
self.loadAddons() self.loadAddons()
def files(self): def allAddons(self):
return [f for f in os.listdir(self.addonsFolder()) l = []
if f.endswith(".py")] for d in os.listdir(self.addonsFolder()):
path = self.addonsFolder(d)
if not os.path.exists(os.path.join(path, "__init__.py")):
continue
l.append(d)
return l
def directories(self): def managedAddons(self):
return [d for d in os.listdir(self.addonsFolder()) return [d for d in self.allAddons()
if not d.startswith('.') and if re.match("^\d+$", d)]
not d == "__pycache__" and
os.path.isdir(os.path.join(self.addonsFolder(), d))] def addonsFolder(self, dir=None):
root = self.mw.pm.addonFolder()
if not dir:
return root
return os.path.join(root, dir)
def loadAddons(self): def loadAddons(self):
for file in self.files(): for dir in self.allAddons():
meta = self.addonMeta(dir)
if meta.get("disabled"):
continue
self.dirty = True
try: try:
__import__(file.replace(".py", "")) __import__(dir)
except: except:
traceback.print_exc() showWarning(_("""\
for directory in self.directories(): An add-on you installed failed to load. If problems persist, please \
try: go to the Tools>Add-ons menu, and disable or delete the add-on.
__import__(directory)
except:
traceback.print_exc()
self.rebuildAddonsMenu()
# Menus When loading '%(name)s':
%(traceback)s
""") % dict(name=meta.get("name", dir), traceback=traceback.format_exc()))
def onAddonsDialog(self):
AddonsDialog(self)
# Metadata
###################################################################### ######################################################################
def onOpenAddonFolder(self, checked, path=None): def _addonMetaPath(self, dir):
if path is None: return os.path.join(self.addonsFolder(dir), "meta.json")
path = self.addonsFolder()
openFolder(path)
def rebuildAddonsMenu(self): def addonMeta(self, dir):
for m in self._menus: path = self._addonMetaPath(dir)
self.mw.form.menuPlugins.removeAction(m.menuAction()) try:
for file in self.files(): return json.load(open(path, encoding="utf8"))
m = self.mw.form.menuPlugins.addMenu( except:
os.path.splitext(file)[0]) return dict()
self._menus.append(m)
p = os.path.join(self.addonsFolder(), file)
a = QAction(_("Edit..."), self.mw, triggered=lambda x, y=p: self.onEdit(y))
m.addAction(a)
a = QAction(_("Delete..."), self.mw, triggered=lambda x, y=p: self.onRem(y))
m.addAction(a)
def onEdit(self, path): def writeAddonMeta(self, dir, meta):
d = QDialog(self.mw) path = self._addonMetaPath(dir)
frm = aqt.forms.editaddon.Ui_Dialog() json.dump(meta, open(path, "w", encoding="utf8"))
frm.setupUi(d)
d.setWindowTitle(os.path.basename(path))
frm.text.setPlainText(open(path).read())
frm.buttonBox.accepted.connect(lambda: self.onAcceptEdit(path, frm))
d.exec_()
def onAcceptEdit(self, path, frm): def toggleEnabled(self, dir):
open(path, "wb").write(frm.text.toPlainText().encode("utf8")) meta = self.addonMeta(dir)
showInfo(_("Edits saved. Please restart Anki.")) meta['disabled'] = not meta.get("disabled")
self.writeAddonMeta(dir, meta)
def onRem(self, path): def addonName(self, dir):
if not askUser(_("Delete %s?") % os.path.basename(path)): return self.addonMeta(dir).get("name", dir)
return
os.unlink(path)
self.rebuildAddonsMenu()
showInfo(_("Deleted. Please restart Anki."))
# Tools # Installing and deleting add-ons
###################################################################### ######################################################################
def addonsFolder(self): def install(self, sid, data, fname):
dir = self.mw.pm.addonFolder()
return dir
def clearAddonCache(self):
"Clear .pyc files which may cause crashes if Python version updated."
dir = self.addonsFolder()
for curdir, dirs, files in os.walk(dir):
for f in files:
if not f.endswith(".pyc"):
continue
os.unlink(os.path.join(curdir, f))
def registerAddon(self, name, updateId):
# not currently used
return
# Installing add-ons
######################################################################
def onGetAddons(self):
showInfo("""\
Most add-ons built for Anki 2.0.x will not work on this version of Anki \
until they are updated. To avoid errors during startup, please only \
download add-ons that say they support Anki 2.1.x in the description.""")
GetAddons(self.mw)
def install(self, data, fname):
if fname.endswith(".py"):
# .py files go directly into the addon folder
path = os.path.join(self.addonsFolder(), fname)
open(path, "wb").write(data)
return
# .zip file
try: try:
z = ZipFile(io.BytesIO(data)) z = ZipFile(io.BytesIO(data))
except zipfile.BadZipfile: except zipfile.BadZipfile:
showWarning(_("The download was corrupt. Please try again.")) showWarning(_("The download was corrupt. Please try again."))
return return
base = self.addonsFolder()
name = os.path.splitext(fname)[0]
# remove old version first
base = self.addonsFolder(sid)
if os.path.exists(base):
self.deleteAddon(sid)
# extract
os.mkdir(base)
for n in z.namelist(): for n in z.namelist():
if n.endswith("/"): if n.endswith("/"):
# folder; ignore # folder; ignore
@ -145,11 +119,190 @@ download add-ons that say they support Anki 2.1.x in the description.""")
# write # write
z.extract(n, base) z.extract(n, base)
# write metadata
meta = dict(name=name,
mod=intTime())
self.writeAddonMeta(sid, meta)
def deleteAddon(self, dir):
send2trash(self.addonsFolder(dir))
# Downloading
######################################################################
def downloadIds(self, ids):
log = []
errs = []
self.mw.progress.start(immediate=True)
for n in ids:
ret = download(self.mw, n)
if ret[0] == "error":
errs.append(_("Error downloading %(id)s: %(error)s") % dict(id=n, error=ret[1]))
continue
data, fname = ret
self.install(str(n), data, fname)
name = os.path.splitext(fname)[0]
log.append(_("Downloaded %(fname)s" % dict(fname=name)))
self.mw.progress.finish()
return log, errs
# Updating
######################################################################
def checkForUpdates(self):
client = AnkiRequestsClient()
# get mod times
self.mw.progress.start(immediate=True)
try:
# ..of enabled items downloaded from ankiweb
addons = []
for dir in self.managedAddons():
meta = self.addonMeta(dir)
if not meta.get("disabled"):
addons.append(dir)
mods = []
while addons:
chunk = addons[:25]
del addons[:25]
mods.extend(self._getModTimes(client, chunk))
return self._updatedIds(mods)
finally:
self.mw.progress.finish()
def _getModTimes(self, client, chunk):
resp = client.get(
aqt.appShared + "updates/" + ",".join(chunk))
if resp.status_code == 200:
return resp.json()
else:
raise Exception("Unexpected response code from AnkiWeb: {}".format(resp.status_code))
def _updatedIds(self, mods):
updated = []
for dir, ts in mods:
sid = str(dir)
if self.addonMeta(sid).get("mod") < ts:
updated.append(sid)
return updated
# Add-ons Dialog
######################################################################
class AddonsDialog(QDialog):
def __init__(self, addonsManager):
self.mgr = addonsManager
self.mw = addonsManager.mw
super().__init__(self.mw)
f = self.form = aqt.forms.addons.Ui_Dialog()
f.setupUi(self)
f.getAddons.clicked.connect(self.onGetAddons)
f.checkForUpdates.clicked.connect(self.onCheckForUpdates)
f.toggleEnabled.clicked.connect(self.onToggleEnabled)
f.viewPage.clicked.connect(self.onViewPage)
f.viewFiles.clicked.connect(self.onViewFiles)
f.delete_2.clicked.connect(self.onDelete)
self.redrawAddons()
self.show()
def redrawAddons(self):
self.addons = [(self.annotatedName(d), d) for d in self.mgr.allAddons()]
self.addons.sort()
self.form.addonList.clear()
self.form.addonList.addItems([r[0] for r in self.addons])
if self.addons:
self.form.addonList.setCurrentRow(0)
def annotatedName(self, dir):
meta = self.mgr.addonMeta(dir)
buf = self.mgr.addonName(dir)
if meta.get('disabled'):
buf += _(" (disabled)")
return buf
def selectedAddons(self):
idxs = [x.row() for x in self.form.addonList.selectedIndexes()]
return [self.addons[idx][1] for idx in idxs]
def onlyOneSelected(self):
dirs = self.selectedAddons()
if len(dirs) != 1:
showInfo("Please select a single add-on first.")
return
return dirs[0]
def onToggleEnabled(self):
for dir in self.selectedAddons():
self.mgr.toggleEnabled(dir)
self.redrawAddons()
def onViewPage(self):
addon = self.onlyOneSelected()
if not addon:
return
if re.match("^\d+$", addon):
openLink(aqt.appShared + f"info/{addon}")
else:
showWarning(_("Add-on was not downloaded from AnkiWeb."))
def onViewFiles(self):
# if nothing selected, open top level folder
selected = self.selectedAddons()
if not selected:
openFolder(self.mgr.addonsFolder())
return
# otherwise require a single selection
addon = self.onlyOneSelected()
if not addon:
return
path = self.mgr.addonsFolder(addon)
openFolder(path)
def onDelete(self):
selected = self.selectedAddons()
if not selected:
return
if not askUser(_("Delete the %(num)d selected add-ons?") %
dict(num=len(selected))):
return
for dir in selected:
self.mgr.deleteAddon(dir)
self.redrawAddons()
def onGetAddons(self):
GetAddons(self)
def onCheckForUpdates(self):
updated = self.mgr.checkForUpdates()
if not updated:
tooltip(_("No updates available."))
else:
names = [self.mgr.addonName(d) for d in updated]
if askUser(_("Update the following add-ons?") +
"\n" + "\n".join(names)):
log, errs = self.mgr.downloadIds(updated)
if log:
tooltip("\n".join(log), parent=self)
if errs:
showWarning("\n".join(errs), parent=self)
self.redrawAddons()
# Fetching Add-ons
######################################################################
class GetAddons(QDialog): class GetAddons(QDialog):
def __init__(self, mw): def __init__(self, dlg):
QDialog.__init__(self, mw) QDialog.__init__(self, dlg)
self.mw = mw self.addonsDlg = dlg
self.mgr = dlg.mgr
self.mw = self.mgr.mw
self.form = aqt.forms.getaddons.Ui_Dialog() self.form = aqt.forms.getaddons.Ui_Dialog()
self.form.setupUi(self) self.form.setupUi(self)
b = self.form.buttonBox.addButton( b = self.form.buttonBox.addButton(
@ -160,11 +313,9 @@ class GetAddons(QDialog):
saveGeom(self, "getaddons") saveGeom(self, "getaddons")
def onBrowse(self): def onBrowse(self):
openLink(aqt.appShared + "addons/") openLink(aqt.appShared + "addons/?v=2.1")
def accept(self): def accept(self):
QDialog.accept(self)
# get codes # get codes
try: try:
ids = [int(n) for n in self.form.code.text().split()] ids = [int(n) for n in self.form.code.text().split()]
@ -172,18 +323,12 @@ class GetAddons(QDialog):
showWarning(_("Invalid code.")) showWarning(_("Invalid code."))
return return
errors = [] log, errs = self.mgr.downloadIds(ids)
self.mw.progress.start(immediate=True) if log:
for n in ids: tooltip("\n".join(log), parent=self.addonsDlg)
ret = download(self.mw, n) if errs:
if ret[0] == "error": showWarning("\n".join(errs))
errors.append(_("Error downloading %(id)s: %(error)s") % dict(id=n, error=ret[1]))
continue self.addonsDlg.redrawAddons()
data, fname = ret QDialog.accept(self)
self.mw.addonManager.install(data, fname)
self.mw.progress.finish()
if not errors:
tooltip(_("Download successful. Please restart Anki."), period=3000)
else:
showWarning("\n".join(errors))

View File

@ -10,7 +10,7 @@ from anki.hooks import addHook, remHook
import aqt import aqt
def download(mw, code): def download(mw, code):
"Download addon/deck from AnkiWeb. Caller must start & stop progress diag." "Download addon from AnkiWeb. Caller must start & stop progress diag."
# create downloading thread # create downloading thread
thread = Downloader(code) thread = Downloader(code)
def onRecv(): def onRecv():
@ -53,11 +53,11 @@ class Downloader(QThread):
client = AnkiRequestsClient() client = AnkiRequestsClient()
try: try:
resp = client.get( resp = client.get(
aqt.appShared + "download/%d" % self.code) aqt.appShared + "download/%s?v=2.1" % self.code)
if resp.status_code == 200: if resp.status_code == 200:
data = client.streamContent(resp) data = client.streamContent(resp)
elif resp.status_code in (403,404): elif resp.status_code in (403,404):
self.error = _("Invalid code") self.error = _("Invalid code, or add-on not available for your version of Anki.")
return return
else: else:
self.error = _("Error downloading: %s" % resp.status_code) self.error = _("Error downloading: %s" % resp.status_code)

View File

@ -105,16 +105,17 @@ or your deck may have a problem.
<p>If that doesn't fix the problem, please copy the following<br> <p>If that doesn't fix the problem, please copy the following<br>
into a bug report:""") into a bug report:""")
pluginText = _("""\ pluginText = _("""\
An error occurred in an add-on.<br><br> <p>An error occurred. Please start Anki while holding down the shift \
Please start Anki while holding down the shift key, and see if<br> key, which will temporarily disable the add-ons you have installed.</p>
the error goes away.<br><br>
If the error goes away, please report the issue on the add-on<br> <p>If the problem occurs even with add-ons disabled, please report the \
forum: %s issue on our support site.</p>
<br><br>If the error occurs even with add-ons disabled, please<br>
report the issue on our support site. <p>If the issue only occurs when add-ons are enabled, plesae use the \
Tools&gt;Add-ons menu item to disable one add-on and restart Anki, \
repeating until you discover the add-on that is causing the problem.</p>
""") """)
pluginText %= "https://anki.tenderapp.com/discussions/add-ons" if self.mw.addonManager.dirty:
if "addon" in error:
txt = pluginText txt = pluginText
else: else:
txt = stdText txt = stdText

104
designer/addons.ui Normal file
View File

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="windowModality">
<enum>Qt::ApplicationModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>577</width>
<height>379</height>
</rect>
</property>
<property name="windowTitle">
<string>Add-ons</string>
</property>
<property name="modal">
<bool>true</bool>
</property>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Changes will take effect when Anki is restarted.</string>
</property>
</widget>
</item>
<item>
<widget class="QListWidget" name="addonList">
<property name="selectionMode">
<enum>QAbstractItemView::ContiguousSelection</enum>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="getAddons">
<property name="text">
<string>Get Add-ons...</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="checkForUpdates">
<property name="text">
<string>Check for Updates</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="viewPage">
<property name="text">
<string>View Add-on Page</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="viewFiles">
<property name="text">
<string>View Files</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="toggleEnabled">
<property name="text">
<string>Toggle Enabled</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="delete_2">
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -79,14 +79,6 @@
<property name="title"> <property name="title">
<string>&amp;Tools</string> <string>&amp;Tools</string>
</property> </property>
<widget class="QMenu" name="menuPlugins">
<property name="title">
<string>&amp;Add-ons</string>
</property>
<addaction name="actionDownloadSharedPlugin"/>
<addaction name="actionOpenPluginFolder"/>
<addaction name="separator"/>
</widget>
<addaction name="actionStudyDeck"/> <addaction name="actionStudyDeck"/>
<addaction name="actionCreateFiltered"/> <addaction name="actionCreateFiltered"/>
<addaction name="separator"/> <addaction name="separator"/>
@ -94,7 +86,7 @@
<addaction name="actionCheckMediaDatabase"/> <addaction name="actionCheckMediaDatabase"/>
<addaction name="actionEmptyCards"/> <addaction name="actionEmptyCards"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="menuPlugins"/> <addaction name="actionAdd_ons"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionNoteTypes"/> <addaction name="actionNoteTypes"/>
<addaction name="actionPreferences"/> <addaction name="actionPreferences"/>
@ -237,6 +229,11 @@
<string>Ctrl+Shift+N</string> <string>Ctrl+Shift+N</string>
</property> </property>
</action> </action>
<action name="actionAdd_ons">
<property name="text">
<string>Add-ons...</string>
</property>
</action>
</widget> </widget>
<resources> <resources>
<include location="icons.qrc"/> <include location="icons.qrc"/>