2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
2012-12-21 08:51:59 +01:00
# -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
2016-08-02 03:51:44 +02:00
import io
2017-08-26 07:14:20 +02:00
import json
2020-01-03 16:32:20 +01:00
import os
2017-08-26 07:14:20 +02:00
import re
2015-01-05 02:47:05 +01:00
import zipfile
2019-02-22 21:14:42 +01:00
from collections import defaultdict
2020-01-03 17:57:33 +01:00
from typing import IO , Any , Callable , Dict , List , NamedTuple , Optional , Tuple , Union
2019-12-20 10:19:03 +01:00
from zipfile import ZipFile
2019-12-15 23:51:38 +01:00
2019-04-24 21:44:11 +02:00
import jsonschema
2019-12-20 10:19:03 +01:00
import markdown
2019-04-24 21:44:11 +02:00
from jsonschema . exceptions import ValidationError
2019-12-20 10:19:03 +01:00
from send2trash import send2trash
2017-08-26 07:14:20 +02:00
2012-12-21 08:51:59 +01:00
import aqt
2019-12-20 10:19:03 +01:00
import aqt . forms
2019-03-04 02:58:34 +01:00
from anki . lang import _ , ngettext
2017-08-26 07:14:20 +02:00
from anki . sync import AnkiRequestsClient
2019-12-20 10:19:03 +01:00
from anki . utils import intTime
from aqt . downloader import download
from aqt . qt import *
2019-12-23 01:34:10 +01:00
from aqt . utils import (
askUser ,
getFile ,
isWin ,
openFolder ,
openLink ,
restoreGeom ,
restoreSplitter ,
saveGeom ,
saveSplitter ,
showInfo ,
showWarning ,
tooltip ,
)
2019-12-20 10:19:03 +01:00
2012-12-21 08:51:59 +01:00
2020-01-03 16:32:20 +01:00
class AddonInstallationResult ( NamedTuple ) :
success : bool
errmsg : Optional [ str ] = None
name : Optional [ str ] = None
conflicts : Optional [ List [ str ] ] = None
2017-02-06 23:21:33 +01:00
class AddonManager :
2012-12-21 08:51:59 +01:00
2020-01-03 18:23:28 +01:00
ext : str = " .ankiaddon "
_manifest_schema : dict = {
2019-04-24 21:44:11 +02:00
" type " : " object " ,
" properties " : {
" package " : { " type " : " string " , " meta " : False } ,
" name " : { " type " : " string " , " meta " : True } ,
" mod " : { " type " : " number " , " meta " : True } ,
2019-12-23 01:34:10 +01:00
" conflicts " : { " type " : " array " , " items " : { " type " : " string " } , " meta " : True } ,
2019-04-24 21:44:11 +02:00
} ,
2019-12-23 01:34:10 +01:00
" required " : [ " package " , " name " ] ,
2019-02-22 17:04:07 +01:00
}
2019-02-22 10:17:56 +01:00
2012-12-21 08:51:59 +01:00
def __init__ ( self , mw ) :
self . mw = mw
2017-08-26 07:14:20 +02:00
self . dirty = False
2016-05-31 10:51:40 +02:00
f = self . mw . form
2017-08-26 07:14:20 +02:00
f . actionAdd_ons . triggered . connect ( self . onAddonsDialog )
2012-12-21 08:51:59 +01:00
sys . path . insert ( 0 , self . addonsFolder ( ) )
2017-08-26 07:14:20 +02:00
def allAddons ( self ) :
l = [ ]
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 )
2018-02-24 03:23:15 +01:00
l . sort ( )
if os . getenv ( " ANKIREVADDONS " , " " ) :
l = reversed ( l )
2017-08-26 07:14:20 +02:00
return l
def managedAddons ( self ) :
2019-12-23 01:34:10 +01:00
return [ d for d in self . allAddons ( ) if re . match ( r " ^ \ d+$ " , d ) ]
2012-12-21 08:51:59 +01:00
2017-08-26 07:14:20 +02:00
def addonsFolder ( self , dir = None ) :
root = self . mw . pm . addonFolder ( )
if not dir :
return root
return os . path . join ( root , dir )
2015-09-27 00:55:15 +02:00
2012-12-21 08:51:59 +01:00
def loadAddons ( self ) :
2017-08-26 07:14:20 +02:00
for dir in self . allAddons ( ) :
meta = self . addonMeta ( dir )
if meta . get ( " disabled " ) :
continue
self . dirty = True
2015-09-27 00:55:15 +02:00
try :
2017-08-26 07:14:20 +02:00
__import__ ( dir )
2015-09-27 00:55:15 +02:00
except :
2019-12-23 01:34:10 +01:00
showWarning (
_ (
""" \
2017-08-26 07:14:20 +02:00
An add - on you installed failed to load . If problems persist , please \
go to the Tools > Add - ons menu , and disable or delete the add - on .
2012-12-21 08:51:59 +01:00
2017-08-26 07:14:20 +02:00
When loading ' %(name)s ' :
% ( traceback ) s
2019-12-23 01:34:10 +01:00
"""
)
% dict ( name = meta . get ( " name " , dir ) , traceback = traceback . format_exc ( ) )
)
2012-12-21 08:51:59 +01:00
2017-08-26 07:14:20 +02:00
def onAddonsDialog ( self ) :
AddonsDialog ( self )
2012-12-21 08:51:59 +01:00
2017-08-26 07:14:20 +02:00
# Metadata
2012-12-21 08:51:59 +01:00
######################################################################
2017-08-26 07:14:20 +02:00
def _addonMetaPath ( self , dir ) :
return os . path . join ( self . addonsFolder ( dir ) , " meta.json " )
2012-12-21 08:51:59 +01:00
2017-08-26 07:14:20 +02:00
def addonMeta ( self , dir ) :
path = self . _addonMetaPath ( dir )
try :
2017-12-11 08:25:51 +01:00
with open ( path , encoding = " utf8 " ) as f :
return json . load ( f )
2017-08-26 07:14:20 +02:00
except :
return dict ( )
def writeAddonMeta ( self , dir , meta ) :
path = self . _addonMetaPath ( dir )
2017-12-11 08:25:51 +01:00
with open ( path , " w " , encoding = " utf8 " ) as f :
2018-12-15 00:13:10 +01:00
json . dump ( meta , f )
2017-08-26 07:14:20 +02:00
2019-02-22 21:14:42 +01:00
def isEnabled ( self , dir ) :
2017-08-26 07:14:20 +02:00
meta = self . addonMeta ( dir )
2019-12-23 01:34:10 +01:00
return not meta . get ( " disabled " )
2019-02-22 21:14:42 +01:00
def toggleEnabled ( self , dir , enable = None ) :
meta = self . addonMeta ( dir )
enabled = enable if enable is not None else meta . get ( " disabled " )
2019-04-16 09:43:02 +02:00
if enabled is True :
conflicting = self . _disableConflicting ( dir )
if conflicting :
addons = " , " . join ( self . addonName ( f ) for f in conflicting )
showInfo (
2019-12-23 01:34:10 +01:00
_ (
" The following add-ons are incompatible with %(name)s \
and have been disabled : % ( found ) s "
)
% dict ( name = self . addonName ( dir ) , found = addons ) ,
textFormat = " plain " ,
)
meta [ " disabled " ] = not enabled
2017-08-26 07:14:20 +02:00
self . writeAddonMeta ( dir , meta )
2012-12-21 08:51:59 +01:00
2017-08-26 07:14:20 +02:00
def addonName ( self , dir ) :
return self . addonMeta ( dir ) . get ( " name " , dir )
2012-12-21 08:51:59 +01:00
2019-02-24 14:51:19 +01:00
def annotatedName ( self , dir ) :
buf = self . addonName ( dir )
if not self . isEnabled ( dir ) :
buf + = _ ( " (disabled) " )
return buf
2019-02-22 21:14:42 +01:00
# Conflict resolution
######################################################################
def addonConflicts ( self , dir ) :
return self . addonMeta ( dir ) . get ( " conflicts " , [ ] )
def allAddonConflicts ( self ) :
all_conflicts = defaultdict ( list )
for dir in self . allAddons ( ) :
if not self . isEnabled ( dir ) :
continue
conflicts = self . addonConflicts ( dir )
for other_dir in conflicts :
all_conflicts [ other_dir ] . append ( dir )
return all_conflicts
2019-04-16 09:43:02 +02:00
def _disableConflicting ( self , dir , conflicts = None ) :
2019-02-22 21:14:42 +01:00
conflicts = conflicts or self . addonConflicts ( dir )
installed = self . allAddons ( )
found = [ d for d in conflicts if d in installed and self . isEnabled ( d ) ]
found . extend ( self . allAddonConflicts ( ) . get ( dir , [ ] ) )
if not found :
2019-04-16 09:43:02 +02:00
return [ ]
2019-02-22 21:14:42 +01:00
for package in found :
self . toggleEnabled ( package , enable = False )
2019-12-23 01:34:10 +01:00
2019-04-16 09:43:02 +02:00
return found
2019-02-22 21:14:42 +01:00
2017-08-26 07:14:20 +02:00
# Installing and deleting add-ons
2012-12-21 08:51:59 +01:00
######################################################################
2019-04-24 21:44:11 +02:00
def readManifestFile ( self , zfile ) :
2015-01-05 02:47:05 +01:00
try :
2019-02-22 17:04:07 +01:00
with zfile . open ( " manifest.json " ) as f :
data = json . loads ( f . read ( ) )
2019-04-24 21:44:11 +02:00
jsonschema . validate ( data , self . _manifest_schema )
# build new manifest from recognized keys
schema = self . _manifest_schema [ " properties " ]
manifest = { key : data [ key ] for key in data . keys ( ) & schema . keys ( ) }
except ( KeyError , json . decoder . JSONDecodeError , ValidationError ) :
2019-02-22 17:04:07 +01:00
# raised for missing manifest, invalid json, missing/invalid keys
return { }
return manifest
2017-08-26 07:14:20 +02:00
2020-01-03 17:57:33 +01:00
def install (
self , file : Union [ IO , str ] , manifest : dict = None
) - > AddonInstallationResult :
2019-02-22 17:04:07 +01:00
""" Install add-on from path or file-like object. Metadata is read
2019-04-16 09:44:00 +02:00
from the manifest file , with keys overriden by supplying a ' manifest '
dictionary """
2019-02-18 07:17:14 +01:00
try :
2019-02-22 17:04:07 +01:00
zfile = ZipFile ( file )
2019-02-18 07:17:14 +01:00
except zipfile . BadZipfile :
2020-01-03 16:32:20 +01:00
return AddonInstallationResult ( success = False , errmsg = " zip " )
2019-12-23 01:34:10 +01:00
2019-02-22 17:04:07 +01:00
with zfile :
2019-04-24 21:44:11 +02:00
file_manifest = self . readManifestFile ( zfile )
2019-04-16 09:44:00 +02:00
if manifest :
file_manifest . update ( manifest )
manifest = file_manifest
2019-02-22 17:04:07 +01:00
if not manifest :
2020-01-03 16:32:20 +01:00
return AddonInstallationResult ( success = False , errmsg = " manifest " )
2019-02-22 17:04:07 +01:00
package = manifest [ " package " ]
2019-02-22 21:14:42 +01:00
conflicts = manifest . get ( " conflicts " , [ ] )
2019-12-23 01:34:10 +01:00
found_conflicts = self . _disableConflicting ( package , conflicts )
2019-02-22 17:04:07 +01:00
meta = self . addonMeta ( package )
self . _install ( package , zfile )
2019-12-23 01:34:10 +01:00
2019-04-24 21:44:11 +02:00
schema = self . _manifest_schema [ " properties " ]
2019-12-23 01:34:10 +01:00
manifest_meta = {
k : v for k , v in manifest . items ( ) if k in schema and schema [ k ] [ " meta " ]
}
2019-02-22 17:04:07 +01:00
meta . update ( manifest_meta )
self . writeAddonMeta ( package , meta )
2019-02-18 07:17:14 +01:00
2020-01-03 16:32:20 +01:00
return AddonInstallationResult (
success = True , name = meta [ " name " ] , conflicts = found_conflicts
)
2019-02-18 07:17:14 +01:00
2019-02-22 17:04:07 +01:00
def _install ( self , dir , zfile ) :
2017-08-28 12:51:43 +02:00
# previously installed?
2019-02-18 07:17:14 +01:00
base = self . addonsFolder ( dir )
2017-08-26 07:14:20 +02:00
if os . path . exists ( base ) :
2019-02-18 07:17:14 +01:00
self . backupUserFiles ( dir )
2019-04-29 10:43:10 +02:00
if not self . deleteAddon ( dir ) :
self . restoreUserFiles ( dir )
return
2017-08-26 07:14:20 +02:00
os . mkdir ( base )
2019-02-18 07:17:14 +01:00
self . restoreUserFiles ( dir )
2017-09-10 10:53:47 +02:00
# extract
2019-02-18 07:17:14 +01:00
for n in zfile . namelist ( ) :
2012-12-21 08:51:59 +01:00
if n . endswith ( " / " ) :
# folder; ignore
continue
2017-09-10 10:53:47 +02:00
path = os . path . join ( base , n )
# skip existing user files
if os . path . exists ( path ) and n . startswith ( " user_files/ " ) :
continue
2019-02-18 07:17:14 +01:00
zfile . extract ( n , base )
2012-12-21 08:51:59 +01:00
2019-04-29 10:43:10 +02:00
# true on success
2017-08-26 07:14:20 +02:00
def deleteAddon ( self , dir ) :
2019-04-29 10:43:10 +02:00
try :
send2trash ( self . addonsFolder ( dir ) )
return True
except OSError as e :
2019-12-23 01:34:10 +01:00
showWarning (
_ (
" Unable to update or delete add-on. Please start Anki while holding down the shift key to temporarily disable add-ons, then try again. \n \n Debug info: %s "
)
% e ,
textFormat = " plain " ,
)
2019-04-29 10:43:10 +02:00
return False
2017-08-26 07:14:20 +02:00
2019-02-18 07:17:14 +01:00
# Processing local add-on files
######################################################################
2019-12-23 01:34:10 +01:00
2020-01-03 17:57:33 +01:00
def processPackages (
self , paths : List [ str ] , parent : QWidget = None
) - > Tuple [ List [ str ] , List [ str ] ] :
2019-02-18 07:17:14 +01:00
log = [ ]
errs = [ ]
2020-01-03 16:32:20 +01:00
2020-01-03 17:57:33 +01:00
self . mw . progress . start ( immediate = True , parent = parent )
2019-02-27 05:08:20 +01:00
try :
for path in paths :
base = os . path . basename ( path )
2020-01-03 16:32:20 +01:00
result = self . install ( path )
if not result . success :
errs . extend (
self . _installationErrorReport ( result , base , mode = " local " )
2019-12-23 01:34:10 +01:00
)
2019-02-27 05:08:20 +01:00
else :
2020-01-03 16:32:20 +01:00
log . extend (
self . _installationSuccessReport ( result , base , mode = " local " )
)
2019-02-27 05:08:20 +01:00
finally :
self . mw . progress . finish ( )
2020-01-03 16:32:20 +01:00
2019-02-18 07:17:14 +01:00
return log , errs
2020-01-03 16:32:20 +01:00
# Downloading add-ons from AnkiWeb
2017-08-26 07:14:20 +02:00
######################################################################
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 " :
2019-12-23 01:34:10 +01:00
errs . append (
_ ( " Error downloading %(id)s : %(error)s " ) % dict ( id = n , error = ret [ 1 ] )
)
2017-08-26 07:14:20 +02:00
continue
data , fname = ret
2017-09-10 09:02:32 +02:00
fname = fname . replace ( " _ " , " " )
2017-08-26 07:14:20 +02:00
name = os . path . splitext ( fname ) [ 0 ]
2020-01-03 16:32:20 +01:00
result = self . install (
2019-12-23 01:34:10 +01:00
io . BytesIO ( data ) ,
manifest = { " package " : str ( n ) , " name " : name , " mod " : intTime ( ) } ,
)
2020-01-03 16:32:20 +01:00
if not result . success :
errs . extend ( self . _installationErrorReport ( result , n ) )
2019-04-16 09:43:02 +02:00
else :
2020-01-03 16:32:20 +01:00
log . extend ( self . _installationSuccessReport ( result , n ) )
2019-04-16 09:43:02 +02:00
2017-08-26 07:14:20 +02:00
self . mw . progress . finish ( )
return log , errs
2020-01-03 16:32:20 +01:00
# Installation messaging
######################################################################
def _installationErrorReport (
self , result : AddonInstallationResult , base : str , mode = " download "
) - > List [ str ] :
messages = {
" zip " : _ ( " Corrupt add-on file. " ) ,
" manifest " : _ ( " Invalid add-on manifest. " ) ,
}
if result . errmsg :
msg = messages . get (
result . errmsg , _ ( " Unknown error: {} " . format ( result . errmsg ) )
)
else :
msg = _ ( " Unknown error " )
if mode == " download " : # preserve old format strings for i18n
template = _ ( " Error downloading <i> %(id)s </i>: %(error)s " )
else :
template = _ ( " Error installing <i> %(base)s </i>: %(error)s " )
name = result . name or base
return [ template % dict ( base = name , id = name , error = msg ) ]
def _installationSuccessReport (
self , result : AddonInstallationResult , base : str , mode = " download "
) - > List [ str ] :
if mode == " download " : # preserve old format strings for i18n
template = _ ( " Downloaded %(fname)s " )
else :
template = _ ( " Installed %(name)s " )
name = result . name or base
strings = [ template % dict ( name = name , fname = name ) ]
if result . conflicts :
strings . append (
_ ( " The following conflicting add-ons were disabled: " )
+ " "
+ " " . join ( result . conflicts )
)
return strings
2017-08-26 07:14:20 +02:00
# 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 ) :
2019-12-23 01:34:10 +01:00
resp = client . get ( aqt . appShared + " updates/ " + " , " . join ( chunk ) )
2017-08-26 07:14:20 +02:00
if resp . status_code == 200 :
return resp . json ( )
else :
2019-12-23 01:34:10 +01:00
raise Exception (
" Unexpected response code from AnkiWeb: {} " . format ( resp . status_code )
)
2017-08-26 07:14:20 +02:00
def _updatedIds ( self , mods ) :
updated = [ ]
for dir , ts in mods :
sid = str ( dir )
2019-04-08 17:51:15 +02:00
if self . addonMeta ( sid ) . get ( " mod " , 0 ) < ( ts or 0 ) :
2017-08-26 07:14:20 +02:00
updated . append ( sid )
return updated
2017-08-28 12:51:43 +02:00
# Add-on Config
######################################################################
2019-12-21 16:48:05 +01:00
_configButtonActions : Dict [ str , Callable [ [ ] , Optional [ bool ] ] ] = { }
2019-12-15 23:51:38 +01:00
_configUpdatedActions : Dict [ str , Callable [ [ Any ] , None ] ] = { }
2017-08-28 12:51:43 +02:00
def addonConfigDefaults ( self , dir ) :
path = os . path . join ( self . addonsFolder ( dir ) , " config.json " )
try :
2017-12-11 08:25:51 +01:00
with open ( path , encoding = " utf8 " ) as f :
return json . load ( f )
2017-08-28 12:51:43 +02:00
except :
return None
def addonConfigHelp ( self , dir ) :
path = os . path . join ( self . addonsFolder ( dir ) , " config.md " )
if os . path . exists ( path ) :
2018-03-01 03:54:54 +01:00
with open ( path , encoding = " utf-8 " ) as f :
2017-12-11 08:25:51 +01:00
return markdown . markdown ( f . read ( ) )
2017-08-28 12:51:43 +02:00
else :
return " "
2017-08-31 06:41:00 +02:00
def addonFromModule ( self , module ) :
return module . split ( " . " ) [ 0 ]
def configAction ( self , addon ) :
return self . _configButtonActions . get ( addon )
2018-07-28 09:00:49 +02:00
def configUpdatedAction ( self , addon ) :
return self . _configUpdatedActions . get ( addon )
2017-08-31 06:41:00 +02:00
# Add-on Config API
######################################################################
2019-12-22 00:12:09 +01:00
def getConfig ( self , module : str ) - > Optional [ dict ] :
2017-08-31 06:41:00 +02:00
addon = self . addonFromModule ( module )
2017-08-30 07:31:03 +02:00
# get default config
config = self . addonConfigDefaults ( addon )
if config is None :
return None
# merge in user's keys
2017-08-28 12:51:43 +02:00
meta = self . addonMeta ( addon )
2017-08-30 07:31:03 +02:00
userConf = meta . get ( " config " , { } )
config . update ( userConf )
return config
2017-08-28 12:51:43 +02:00
2019-12-22 00:12:09 +01:00
def setConfigAction ( self , module : str , fn : Callable [ [ ] , Optional [ bool ] ] ) :
2017-08-31 06:41:00 +02:00
addon = self . addonFromModule ( module )
2017-08-28 12:51:43 +02:00
self . _configButtonActions [ addon ] = fn
2019-12-22 00:12:09 +01:00
def setConfigUpdatedAction ( self , module : str , fn : Callable [ [ Any ] , None ] ) :
2018-07-28 09:00:49 +02:00
addon = self . addonFromModule ( module )
self . _configUpdatedActions [ addon ] = fn
2019-12-22 00:12:09 +01:00
def writeConfig ( self , module : str , conf : dict ) :
2017-08-31 06:41:00 +02:00
addon = self . addonFromModule ( module )
2017-08-28 12:51:43 +02:00
meta = self . addonMeta ( addon )
2019-12-23 01:34:10 +01:00
meta [ " config " ] = conf
2017-08-28 12:51:43 +02:00
self . writeAddonMeta ( addon , meta )
2017-09-10 10:53:47 +02:00
# user_files
######################################################################
def _userFilesPath ( self , sid ) :
return os . path . join ( self . addonsFolder ( sid ) , " user_files " )
def _userFilesBackupPath ( self ) :
return os . path . join ( self . addonsFolder ( ) , " files_backup " )
def backupUserFiles ( self , sid ) :
p = self . _userFilesPath ( sid )
if os . path . exists ( p ) :
os . rename ( p , self . _userFilesBackupPath ( ) )
def restoreUserFiles ( self , sid ) :
p = self . _userFilesPath ( sid )
bp = self . _userFilesBackupPath ( )
# did we back up userFiles?
if not os . path . exists ( bp ) :
return
os . rename ( bp , p )
2019-12-23 01:34:10 +01:00
2019-03-02 18:57:51 +01:00
# Web Exports
######################################################################
2019-12-15 23:51:38 +01:00
_webExports : Dict [ str , str ] = { }
2019-03-02 18:57:51 +01:00
2019-12-22 00:12:09 +01:00
def setWebExports ( self , module : str , pattern : str ) :
2019-03-02 18:57:51 +01:00
addon = self . addonFromModule ( module )
self . _webExports [ addon ] = pattern
2019-12-23 01:34:10 +01:00
2019-03-02 18:57:51 +01:00
def getWebExports ( self , addon ) :
return self . _webExports . get ( addon )
2017-09-10 10:53:47 +02:00
2017-08-26 07:14:20 +02:00
# Add-ons Dialog
######################################################################
2019-12-23 01:34:10 +01:00
class AddonsDialog ( QDialog ) :
2017-08-26 07:14:20 +02:00
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 )
2019-02-18 07:17:14 +01:00
f . installFromFile . clicked . connect ( self . onInstallFiles )
2017-08-26 07:14:20 +02:00
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 )
2017-08-28 12:51:43 +02:00
f . config . clicked . connect ( self . onConfig )
2019-05-13 23:27:27 +02:00
self . form . addonList . itemDoubleClicked . connect ( self . onConfig )
2018-08-31 08:56:16 +02:00
self . form . addonList . currentRowChanged . connect ( self . _onAddonItemSelected )
2019-02-18 07:17:53 +01:00
self . setAcceptDrops ( True )
2017-08-26 07:14:20 +02:00
self . redrawAddons ( )
2019-02-20 05:38:22 +01:00
restoreGeom ( self , " addons " )
2017-08-26 07:14:20 +02:00
self . show ( )
2019-02-18 07:17:53 +01:00
def dragEnterEvent ( self , event ) :
mime = event . mimeData ( )
if not mime . hasUrls ( ) :
return None
urls = mime . urls ( )
2019-02-22 10:17:56 +01:00
ext = self . mgr . ext
if all ( url . toLocalFile ( ) . endswith ( ext ) for url in urls ) :
2019-02-18 07:17:53 +01:00
event . acceptProposedAction ( )
def dropEvent ( self , event ) :
mime = event . mimeData ( )
paths = [ ]
for url in mime . urls ( ) :
path = url . toLocalFile ( )
if os . path . exists ( path ) :
paths . append ( path )
self . onInstallFiles ( paths )
2019-02-20 05:38:22 +01:00
def reject ( self ) :
saveGeom ( self , " addons " )
return QDialog . reject ( self )
2017-08-26 07:14:20 +02:00
def redrawAddons ( self ) :
2019-02-23 10:04:45 +01:00
addonList = self . form . addonList
mgr = self . mgr
2019-12-23 01:34:10 +01:00
2019-02-24 14:51:19 +01:00
self . addons = [ ( mgr . annotatedName ( d ) , d ) for d in mgr . allAddons ( ) ]
2017-08-26 07:14:20 +02:00
self . addons . sort ( )
2019-02-23 10:10:05 +01:00
selected = set ( self . selectedAddons ( ) )
2019-02-23 10:04:45 +01:00
addonList . clear ( )
for name , dir in self . addons :
item = QListWidgetItem ( name , addonList )
if not mgr . isEnabled ( dir ) :
item . setForeground ( Qt . gray )
2019-02-23 10:10:05 +01:00
if dir in selected :
item . setSelected ( True )
2017-08-26 07:14:20 +02:00
2019-02-23 10:04:45 +01:00
addonList . repaint ( )
2019-02-06 00:19:20 +01:00
2018-08-31 08:56:16 +02:00
def _onAddonItemSelected ( self , row_int ) :
try :
addon = self . addons [ row_int ] [ 1 ]
2018-08-31 09:13:06 +02:00
except IndexError :
2019-12-23 01:34:10 +01:00
addon = " "
2019-04-15 14:53:18 +02:00
self . form . viewPage . setEnabled ( bool ( re . match ( r " ^ \ d+$ " , addon ) ) )
2019-12-23 01:34:10 +01:00
self . form . config . setEnabled (
bool ( self . mgr . getConfig ( addon ) or self . mgr . configAction ( addon ) )
)
2018-08-31 08:56:16 +02:00
2017-08-26 07:14:20 +02:00
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 :
2018-09-24 06:48:08 +02:00
showInfo ( _ ( " Please select a single add-on first. " ) )
2017-08-26 07:14:20 +02:00
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
2017-12-11 08:25:51 +01:00
if re . match ( r " ^ \ d+$ " , addon ) :
2018-01-16 07:07:30 +01:00
openLink ( aqt . appShared + " info/ {} " . format ( addon ) )
2017-08-26 07:14:20 +02:00
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
2019-12-23 01:34:10 +01:00
if not askUser (
ngettext (
" Delete the %(num)d selected add-on? " ,
" Delete the %(num)d selected add-ons? " ,
len ( selected ) ,
)
% dict ( num = len ( selected ) )
) :
2017-08-26 07:14:20 +02:00
return
for dir in selected :
2019-04-29 10:43:10 +02:00
if not self . mgr . deleteAddon ( dir ) :
break
2019-02-24 06:24:31 +01:00
self . form . addonList . clearSelection ( )
2017-08-26 07:14:20 +02:00
self . redrawAddons ( )
def onGetAddons ( self ) :
GetAddons ( self )
2020-01-03 17:57:33 +01:00
def onInstallFiles ( self , paths : List [ str ] = None , external : bool = False ) :
2019-02-18 07:17:14 +01:00
if not paths :
2019-12-23 01:34:10 +01:00
key = _ ( " Packaged Anki Add-on " ) + " (* {} ) " . format ( self . mgr . ext )
paths = getFile (
self , _ ( " Install Add-on(s) " ) , None , key , key = " addons " , multi = True
)
2019-02-18 07:17:14 +01:00
if not paths :
return False
2019-12-23 01:34:10 +01:00
2020-01-03 17:57:33 +01:00
installAddonPackages ( self . mgr , paths , parent = self )
2019-02-18 07:17:14 +01:00
self . redrawAddons ( )
2017-08-26 07:14:20 +02:00
def onCheckForUpdates ( self ) :
2019-02-27 05:18:16 +01:00
try :
updated = self . mgr . checkForUpdates ( )
except Exception as e :
2019-12-23 01:34:10 +01:00
showWarning (
_ ( " Please check your internet connection. " ) + " \n \n " + str ( e ) ,
textFormat = " plain " ,
)
2019-02-27 05:18:16 +01:00
return
2017-08-26 07:14:20 +02:00
if not updated :
tooltip ( _ ( " No updates available. " ) )
else :
names = [ self . mgr . addonName ( d ) for d in updated ]
2019-12-23 01:34:10 +01:00
if askUser ( _ ( " Update the following add-ons? " ) + " \n " + " \n " . join ( names ) ) :
2017-08-26 07:14:20 +02:00
log , errs = self . mgr . downloadIds ( updated )
if log :
2019-04-16 09:38:38 +02:00
log_html = " <br> " . join ( log )
if len ( log ) == 1 :
tooltip ( log_html , parent = self )
else :
showInfo ( log_html , parent = self , textFormat = " rich " )
2017-08-26 07:14:20 +02:00
if errs :
2019-02-26 00:36:02 +01:00
showWarning ( " \n \n " . join ( errs ) , parent = self , textFormat = " plain " )
2017-08-26 07:14:20 +02:00
self . redrawAddons ( )
2017-08-28 12:51:43 +02:00
def onConfig ( self ) :
addon = self . onlyOneSelected ( )
if not addon :
return
# does add-on manage its own config?
act = self . mgr . configAction ( addon )
if act :
2019-12-21 16:48:05 +01:00
ret = act ( )
if ret is not False :
return
2017-08-28 12:51:43 +02:00
conf = self . mgr . getConfig ( addon )
if conf is None :
showInfo ( _ ( " Add-on has no configuration. " ) )
return
ConfigEditor ( self , addon , conf )
2017-08-26 07:14:20 +02:00
# Fetching Add-ons
######################################################################
2012-12-21 08:51:59 +01:00
2019-12-23 01:34:10 +01:00
class GetAddons ( QDialog ) :
2017-08-26 07:14:20 +02:00
def __init__ ( self , dlg ) :
QDialog . __init__ ( self , dlg )
self . addonsDlg = dlg
self . mgr = dlg . mgr
self . mw = self . mgr . mw
2012-12-21 08:51:59 +01:00
self . form = aqt . forms . getaddons . Ui_Dialog ( )
self . form . setupUi ( self )
b = self . form . buttonBox . addButton (
2019-12-23 01:34:10 +01:00
_ ( " Browse Add-ons " ) , QDialogButtonBox . ActionRole
)
2016-05-31 10:51:40 +02:00
b . clicked . connect ( self . onBrowse )
2014-06-18 20:47:45 +02:00
restoreGeom ( self , " getaddons " , adjustSize = True )
2012-12-21 08:51:59 +01:00
self . exec_ ( )
2014-06-18 20:47:45 +02:00
saveGeom ( self , " getaddons " )
2012-12-21 08:51:59 +01:00
def onBrowse ( self ) :
2017-08-26 14:40:11 +02:00
openLink ( aqt . appShared + " addons/2.1 " )
2012-12-21 08:51:59 +01:00
def accept ( self ) :
2017-02-15 06:55:20 +01:00
# get codes
try :
ids = [ int ( n ) for n in self . form . code . text ( ) . split ( ) ]
except ValueError :
showWarning ( _ ( " Invalid code. " ) )
return
2017-08-26 07:14:20 +02:00
log , errs = self . mgr . downloadIds ( ids )
2017-02-15 06:55:20 +01:00
2017-08-26 07:14:20 +02:00
if log :
2019-04-16 09:38:38 +02:00
log_html = " <br> " . join ( log )
if len ( log ) == 1 :
tooltip ( log_html , parent = self )
else :
showInfo ( log_html , parent = self , textFormat = " rich " )
2017-08-26 07:14:20 +02:00
if errs :
2019-02-26 00:36:02 +01:00
showWarning ( " \n \n " . join ( errs ) , textFormat = " plain " )
2017-08-26 07:14:20 +02:00
self . addonsDlg . redrawAddons ( )
QDialog . accept ( self )
2017-08-28 12:51:43 +02:00
2019-12-23 01:34:10 +01:00
2017-08-28 12:51:43 +02:00
# Editing config
######################################################################
2019-12-23 01:34:10 +01:00
class ConfigEditor ( QDialog ) :
2017-08-28 12:51:43 +02:00
def __init__ ( self , dlg , addon , conf ) :
super ( ) . __init__ ( dlg )
self . addon = addon
self . conf = conf
self . mgr = dlg . mgr
self . form = aqt . forms . addonconf . Ui_Dialog ( )
self . form . setupUi ( self )
restore = self . form . buttonBox . button ( QDialogButtonBox . RestoreDefaults )
restore . clicked . connect ( self . onRestoreDefaults )
2019-02-15 14:15:54 +01:00
self . setupFonts ( )
2017-08-28 12:51:43 +02:00
self . updateHelp ( )
2018-07-28 09:09:17 +02:00
self . updateText ( self . conf )
2019-02-23 09:02:06 +01:00
restoreGeom ( self , " addonconf " )
restoreSplitter ( self . form . splitter , " addonconf " )
2017-08-28 12:51:43 +02:00
self . show ( )
def onRestoreDefaults ( self ) :
2018-07-28 09:09:17 +02:00
default_conf = self . mgr . addonConfigDefaults ( self . addon )
self . updateText ( default_conf )
2019-02-23 09:12:55 +01:00
tooltip ( _ ( " Restored defaults " ) , parent = self )
2017-08-28 12:51:43 +02:00
2019-02-15 14:15:54 +01:00
def setupFonts ( self ) :
font_mono = QFontDatabase . systemFont ( QFontDatabase . FixedFont )
2019-02-23 09:02:06 +01:00
font_mono . setPointSize ( font_mono . pointSize ( ) + 1 )
2019-02-15 14:15:54 +01:00
self . form . editor . setFont ( font_mono )
2017-08-28 12:51:43 +02:00
def updateHelp ( self ) :
txt = self . mgr . addonConfigHelp ( self . addon )
if txt :
self . form . label . setText ( txt )
else :
self . form . scrollArea . setVisible ( False )
2018-07-28 09:09:17 +02:00
def updateText ( self , conf ) :
2017-08-28 12:51:43 +02:00
self . form . editor . setPlainText (
2019-12-23 01:34:10 +01:00
json . dumps (
conf ,
ensure_ascii = False ,
sort_keys = True ,
indent = 4 ,
separators = ( " , " , " : " ) ,
)
)
2017-08-28 12:51:43 +02:00
2019-02-23 09:02:06 +01:00
def onClose ( self ) :
saveGeom ( self , " addonconf " )
saveSplitter ( self . form . splitter , " addonconf " )
def reject ( self ) :
self . onClose ( )
super ( ) . reject ( )
2017-08-28 12:51:43 +02:00
def accept ( self ) :
txt = self . form . editor . toPlainText ( )
try :
2018-07-28 09:09:17 +02:00
new_conf = json . loads ( txt )
2017-08-28 12:51:43 +02:00
except Exception as e :
showInfo ( _ ( " Invalid configuration: " ) + repr ( e ) )
return
2018-11-18 06:22:31 +01:00
if not isinstance ( new_conf , dict ) :
showInfo ( _ ( " Invalid configuration: top level object must be a map " ) )
return
2018-07-28 09:09:17 +02:00
if new_conf != self . conf :
self . mgr . writeConfig ( self . addon , new_conf )
2018-07-28 09:25:38 +02:00
# does the add-on define an action to be fired?
2018-07-28 09:09:17 +02:00
act = self . mgr . configUpdatedAction ( self . addon )
if act :
2018-07-28 09:25:38 +02:00
act ( new_conf )
2019-12-23 01:34:10 +01:00
2019-02-23 09:02:06 +01:00
self . onClose ( )
2017-08-28 12:51:43 +02:00
super ( ) . accept ( )
2020-01-03 17:57:33 +01:00
# .ankiaddon installation wizard
######################################################################
def installAddonPackages (
addonsManager : AddonManager ,
paths : List [ str ] ,
parent : QWidget = None ,
external : bool = False ,
) - > bool :
if external :
names_str = " ,<br> " . join ( f " <b> { os . path . basename ( p ) } </b> " for p in paths )
q = _ (
" <b>Important</b>: As add-ons are programs downloaded from the internet, "
" they are potentially malicious. "
" <b>You should only install add-ons you trust.</b><br><br> "
" Are you sure you want to proceed with the installation of the "
f " following add-on(s)?<br><br> { names_str } <i> "
)
if (
not showInfo (
q ,
parent = parent ,
title = _ ( " Install Anki add-on " ) ,
type = " warning " ,
customBtns = [ QMessageBox . No , QMessageBox . Yes ] ,
)
== QMessageBox . Yes
) :
tooltip ( _ ( " Add-on installation aborted " ) , parent = parent )
return False
log , errs = addonsManager . processPackages ( paths , parent = parent )
if log :
log_html = " <br> " . join ( log )
if external :
log_html + = " <br><br> " + _ (
" <b>Please restart Anki to complete the installation.</b> "
)
if len ( log ) == 1 :
tooltip ( log_html , parent = parent )
else :
showInfo (
log_html ,
parent = parent ,
textFormat = " rich " ,
title = _ ( " Installation complete " ) ,
)
if errs :
msg = _ ( " Please report this to the respective add-on author(s). " )
showWarning (
" <br><br> " . join ( errs + [ msg ] ) ,
parent = parent ,
textFormat = " rich " ,
title = _ ( " Add-on installation error " ) ,
)
return not errs