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
2020-01-19 01:37:15 +01:00
from __future__ import annotations
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-19 01:37:15 +01:00
from concurrent . futures import Future
from dataclasses import dataclass
from typing import IO , Any , Callable , Dict , Iterable , List , 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
2020-01-19 02:33:27 +01:00
from anki . httpclient import HttpClient
2019-03-04 02:58:34 +01:00
from anki . lang import _ , ngettext
2019-12-20 10:19:03 +01:00
from anki . utils import intTime
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-19 01:37:15 +01:00
@dataclass
class InstallOk :
name : str
conflicts : List [ str ]
@dataclass
class InstallError :
errmsg : str
@dataclass
class DownloadOk :
data : bytes
filename : str
@dataclass
class DownloadError :
# set if result was not 200
status_code : Optional [ int ] = None
# set if an exception occurred
exception : Optional [ Exception ] = None
2020-01-03 16:32:20 +01:00
2020-01-19 01:37:15 +01:00
@dataclass
class UpdateInfo :
id : int
last_updated : int
max_point_version : Optional [ int ]
# first arg is add-on id
DownloadLogEntry = Tuple [ int , Union [ DownloadError , InstallError , InstallOk ] ]
# fixme: this class should not have any GUI code in it
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
2020-01-19 01:37:15 +01:00
def __init__ ( self , mw : aqt . main . AnkiQt ) :
2012-12-21 08:51:59 +01:00
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
2020-01-19 01:37:15 +01:00
def addonMeta ( self , dir : str ) - > Dict [ str , Any ] :
2017-08-26 07:14:20 +02:00
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
2020-01-19 01:37:15 +01:00
def enabled_addon_ids ( self ) - > List [ int ] :
ids = [ ]
for dir in self . managedAddons ( ) :
meta = self . addonMeta ( dir )
if not meta . get ( " disabled " ) :
ids . append ( int ( dir ) )
return ids
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
2020-01-19 01:37:15 +01:00
) - > Union [ InstallOk , InstallError ] :
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-19 01:37:15 +01:00
return InstallError ( 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-19 01:37:15 +01:00
return InstallError ( 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-19 01:37:15 +01:00
return InstallOk ( 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 )
2020-01-19 01:37:15 +01:00
if isinstance ( result , InstallError ) :
2020-01-03 16:32:20 +01:00
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
# Installation messaging
######################################################################
def _installationErrorReport (
2020-01-19 01:37:15 +01:00
self , result : InstallError , base : str , mode : str = " download "
2020-01-03 16:32:20 +01:00
) - > List [ str ] :
messages = {
" zip " : _ ( " Corrupt add-on file. " ) ,
" manifest " : _ ( " Invalid add-on manifest. " ) ,
}
2020-01-19 01:37:15 +01:00
msg = messages . get ( result . errmsg , _ ( " Unknown error: {} " . format ( result . errmsg ) ) )
2020-01-03 16:32:20 +01:00
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 " )
2020-01-19 01:37:15 +01:00
name = base
2020-01-03 16:32:20 +01:00
return [ template % dict ( base = name , id = name , error = msg ) ]
def _installationSuccessReport (
2020-01-19 01:37:15 +01:00
self , result : InstallOk , base : str , mode : str = " download "
2020-01-03 16:32:20 +01:00
) - > 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: " )
+ " "
2020-01-04 04:49:36 +01:00
+ " , " . join ( self . addonName ( f ) for f in result . conflicts )
2020-01-03 16:32:20 +01:00
)
return strings
2017-08-26 07:14:20 +02:00
# Updating
######################################################################
2020-01-19 01:37:15 +01:00
def update_max_supported_versions ( self , items : List [ UpdateInfo ] ) - > None :
# todo
pass
2017-08-26 07:14:20 +02:00
2020-01-19 01:37:15 +01:00
def updates_required ( self , items : List [ UpdateInfo ] ) - > List [ int ] :
""" Return ids of add-ons requiring an update. """
need_update = [ ]
for item in items :
if not self . addon_is_latest ( item . id , item . last_updated ) :
need_update . append ( item . id )
2017-08-26 07:14:20 +02:00
2020-01-19 01:37:15 +01:00
return need_update
2017-08-26 07:14:20 +02:00
2020-01-19 01:37:15 +01:00
def addon_is_latest ( self , id : int , server_update : int ) - > bool :
return self . addonMeta ( str ( id ) ) . get ( " mod " , 0 ) > = ( server_update or 0 )
2017-08-26 07:14:20 +02:00
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 ) :
2020-01-19 01:37:15 +01:00
def __init__ ( self , addonsManager : AddonManager ) :
2017-08-26 07:14:20 +02:00
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 )
2020-01-19 01:37:15 +01:00
f . checkForUpdates . clicked . connect ( self . check_for_updates )
2017-08-26 07:14:20 +02:00
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
2020-01-19 01:33:51 +01:00
addonList . reset ( )
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 ) :
2020-01-19 01:37:15 +01:00
obj = GetAddons ( self )
if obj . ids :
download_addons ( self , self . mgr , obj . ids , self . after_downloading )
def after_downloading ( self , log : List [ DownloadLogEntry ] ) :
if log :
self . redrawAddons ( )
show_log_to_user ( self , log )
else :
tooltip ( _ ( " No updates available. " ) )
2017-08-26 07:14:20 +02:00
2020-01-04 04:31:33 +01:00
def onInstallFiles ( self , paths : Optional [ List [ str ] ] = None ) :
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 ( )
2020-01-19 01:37:15 +01:00
def check_for_updates ( self ) :
tooltip ( _ ( " Checking... " ) )
check_and_prompt_for_updates ( self , self . mgr , self . after_downloading )
2017-08-26 07:14:20 +02:00
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
2020-01-19 01:37:15 +01:00
self . ids : List [ int ] = [ ]
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
2020-01-19 01:37:15 +01:00
self . ids = ids
QDialog . accept ( self )
2017-02-15 06:55:20 +01:00
2020-01-19 01:37:15 +01:00
# Downloading
######################################################################
2020-01-19 02:33:27 +01:00
def download_addon ( client : HttpClient , id : int ) - > Union [ DownloadOk , DownloadError ] :
2020-01-19 01:37:15 +01:00
" Fetch a single add-on from AnkiWeb. "
try :
resp = client . get ( aqt . appShared + f " download/ { id } ?v=2.1 " )
if resp . status_code != 200 :
return DownloadError ( status_code = resp . status_code )
data = client . streamContent ( resp )
fname = re . match (
" attachment; filename=(.+) " , resp . headers [ " content-disposition " ]
) . group ( 1 )
return DownloadOk ( data = data , filename = fname )
except Exception as e :
return DownloadError ( exception = e )
def download_log_to_html ( log : List [ DownloadLogEntry ] ) - > str :
return " \n " . join ( map ( describe_log_entry , log ) )
def describe_log_entry ( id_and_entry : DownloadLogEntry ) - > str :
( id , entry ) = id_and_entry
buf = f " { id } : "
if isinstance ( entry , DownloadError ) :
if entry . status_code is not None :
if entry . status_code in ( 403 , 404 ) :
buf + = _ (
" Invalid code, or add-on not available for your version of Anki. "
)
2019-04-16 09:38:38 +02:00
else :
2020-01-19 01:37:15 +01:00
buf + = _ ( " Unexpected response code: %s " ) % entry . status_code
else :
buf + = (
_ ( " Please check your internet connection. " )
+ " \n \n "
+ str ( entry . exception )
)
elif isinstance ( entry , InstallError ) :
buf + = entry . errmsg
else :
buf + = _ ( " Installed successfully. " )
2017-08-26 07:14:20 +02:00
2020-01-19 01:37:15 +01:00
return buf
def download_encountered_problem ( log : List [ DownloadLogEntry ] ) - > bool :
return any ( not isinstance ( e [ 1 ] , InstallOk ) for e in log )
def download_and_install_addon (
2020-01-19 02:33:27 +01:00
mgr : AddonManager , client : HttpClient , id : int
2020-01-19 01:37:15 +01:00
) - > DownloadLogEntry :
" Download and install a single add-on. "
result = download_addon ( client , id )
if isinstance ( result , DownloadError ) :
return ( id , result )
fname = result . filename . replace ( " _ " , " " )
name = os . path . splitext ( fname ) [ 0 ]
result2 = mgr . install (
io . BytesIO ( result . data ) ,
manifest = { " package " : str ( id ) , " name " : name , " mod " : intTime ( ) } ,
)
return ( id , result2 )
class DownloaderInstaller ( QObject ) :
progressSignal = pyqtSignal ( int , int )
2020-01-19 02:33:27 +01:00
def __init__ ( self , parent : QWidget , mgr : AddonManager , client : HttpClient ) - > None :
2020-01-19 01:37:15 +01:00
QObject . __init__ ( self , parent )
self . mgr = mgr
self . client = client
self . progressSignal . connect ( self . _progress_callback ) # type: ignore
def bg_thread_progress ( up , down ) :
self . progressSignal . emit ( up , down ) # type: ignore
self . client . progress_hook = bg_thread_progress
def download (
self , ids : List [ int ] , on_done : Callable [ [ List [ DownloadLogEntry ] ] , None ]
) - > None :
self . ids = ids
self . log : List [ DownloadLogEntry ] = [ ]
self . dl_bytes = 0
self . last_tooltip = 0
self . on_done = on_done
self . mgr . mw . progress . start ( immediate = True , parent = self . parent ( ) )
self . mgr . mw . taskman . run ( self . _download_all , self . _download_done )
def _progress_callback ( self , up : int , down : int ) - > None :
self . dl_bytes + = down
self . mgr . mw . progress . update (
label = _ ( " Downloading %(a)d / %(b)d ( %(kb)0.2f KB)... " )
% dict ( a = len ( self . log ) + 1 , b = len ( self . ids ) , kb = self . dl_bytes / 1024 )
)
def _download_all ( self ) :
for id in self . ids :
self . log . append ( download_and_install_addon ( self . mgr , self . client , id ) )
def _download_done ( self , future ) :
self . mgr . mw . progress . finish ( )
# qt gets confused if on_done() opens new windows while the progress
# modal is still cleaning up
self . mgr . mw . progress . timer ( 50 , lambda : self . on_done ( self . log ) , False )
def show_log_to_user ( parent : QWidget , log : List [ DownloadLogEntry ] ) - > None :
have_problem = download_encountered_problem ( log )
if have_problem :
text = _ ( " One or more errors occurred: " )
else :
text = _ ( " Download complete. Please restart Anki to apply changes. " )
text + = " <br><br> " + download_log_to_html ( log )
if have_problem :
showWarning ( text , textFormat = " rich " , parent = parent )
else :
showInfo ( text , parent = parent )
def download_addons (
parent : QWidget ,
mgr : AddonManager ,
ids : List [ int ] ,
on_done : Callable [ [ List [ DownloadLogEntry ] ] , None ] ,
2020-01-19 02:33:27 +01:00
client : Optional [ HttpClient ] = None ,
2020-01-19 01:37:15 +01:00
) - > None :
if client is None :
2020-01-19 02:33:27 +01:00
client = HttpClient ( )
2020-01-19 01:37:15 +01:00
downloader = DownloaderInstaller ( parent , mgr , client )
downloader . download ( ids , on_done = on_done )
# Update checking
######################################################################
2020-01-19 02:33:27 +01:00
def fetch_update_info ( client : HttpClient , ids : List [ int ] ) - > List [ UpdateInfo ] :
2020-01-19 01:37:15 +01:00
""" Fetch update info from AnkiWeb in one or more batches. """
all_info : List [ UpdateInfo ] = [ ]
while ids :
# get another chunk
chunk = ids [ : 25 ]
del ids [ : 25 ]
batch_results = _fetch_update_info_batch ( client , map ( str , chunk ) )
all_info . extend ( batch_results )
return all_info
def _fetch_update_info_batch (
2020-01-19 02:33:27 +01:00
client : HttpClient , chunk : Iterable [ str ]
2020-01-19 01:37:15 +01:00
) - > Iterable [ UpdateInfo ] :
""" Get update info from AnkiWeb.
Chunk must not contain more than 25 ids . """
resp = client . get ( aqt . appShared + " updates/ " + " , " . join ( chunk ) + " ?v=2 " )
if resp . status_code == 200 :
return json_update_info_to_native ( resp . json ( ) )
else :
raise Exception (
" Unexpected response code from AnkiWeb: {} " . format ( resp . status_code )
)
def json_update_info_to_native ( json_obj : List [ Dict ] ) - > Iterable [ UpdateInfo ] :
def from_json ( d : Dict [ str , Any ] ) - > UpdateInfo :
return UpdateInfo (
id = d [ " id " ] , last_updated = d [ " updated " ] , max_point_version = d [ " maxver " ]
)
return map ( from_json , json_obj )
def check_and_prompt_for_updates (
parent : QWidget ,
mgr : AddonManager ,
on_done : Callable [ [ List [ DownloadLogEntry ] ] , None ] ,
) :
2020-01-19 02:33:27 +01:00
def on_updates_received ( client : HttpClient , items : List [ UpdateInfo ] ) :
2020-01-19 01:37:15 +01:00
handle_update_info ( parent , mgr , client , items , on_done )
check_for_updates ( mgr , on_updates_received )
def check_for_updates (
2020-01-19 02:33:27 +01:00
mgr : AddonManager , on_done : Callable [ [ HttpClient , List [ UpdateInfo ] ] , None ]
2020-01-19 01:37:15 +01:00
) :
2020-01-19 02:33:27 +01:00
client = HttpClient ( )
2020-01-19 01:37:15 +01:00
def check ( ) :
return fetch_update_info ( client , mgr . enabled_addon_ids ( ) )
def update_info_received ( future : Future ) :
# if syncing/in profile screen, defer message delivery
if not mgr . mw . col :
mgr . mw . progress . timer (
1000 ,
lambda : update_info_received ( future ) ,
False ,
requiresCollection = False ,
)
return
if future . exception ( ) :
# swallow network errors
print ( str ( future . exception ( ) ) )
result = [ ]
else :
result = future . result ( )
on_done ( client , result )
mgr . mw . taskman . run ( check , update_info_received )
def handle_update_info (
parent : QWidget ,
mgr : AddonManager ,
2020-01-19 02:33:27 +01:00
client : HttpClient ,
2020-01-19 01:37:15 +01:00
items : List [ UpdateInfo ] ,
on_done : Callable [ [ List [ DownloadLogEntry ] ] , None ] ,
) - > None :
# record maximum supported versions
mgr . update_max_supported_versions ( items )
updated_ids = mgr . updates_required ( items )
if not updated_ids :
on_done ( [ ] )
return
# tooltip(_("No updates available."))
prompt_to_update ( parent , mgr , client , updated_ids , on_done )
def prompt_to_update (
parent : QWidget ,
mgr : AddonManager ,
2020-01-19 02:33:27 +01:00
client : HttpClient ,
2020-01-19 01:37:15 +01:00
ids : List [ int ] ,
on_done : Callable [ [ List [ DownloadLogEntry ] ] , None ] ,
) - > None :
names = map ( lambda x : mgr . addonName ( str ( x ) ) , ids )
if not askUser (
_ ( " The following add-ons have updates available. Install them now? " )
+ " \n \n "
+ " \n " . join ( names )
) :
# on_done is not called if the user cancels
return
download_addons ( parent , mgr , ids , on_done , client )
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 ] ,
2020-01-04 04:34:16 +01:00
parent : Optional [ QWidget ] = None ,
warn : bool = False ,
2020-01-04 04:45:43 +01:00
strictly_modal : bool = False ,
advise_restart : bool = False ,
2020-01-03 17:57:33 +01:00
) - > bool :
2020-01-04 04:34:16 +01:00
if warn :
2020-01-04 01:30:20 +01:00
names = " ,<br> " . join ( f " <b> { os . path . basename ( p ) } </b> " for p in paths )
2020-01-03 17:57:33 +01:00
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 "
2020-01-04 04:34:16 +01:00
" following Anki add-on(s)?<br><br> %(names)s "
2020-01-04 01:30:20 +01:00
) % dict ( names = names )
2020-01-03 17:57:33 +01:00
if (
not showInfo (
q ,
parent = parent ,
title = _ ( " Install Anki add-on " ) ,
type = " warning " ,
customBtns = [ QMessageBox . No , QMessageBox . Yes ] ,
)
== QMessageBox . Yes
) :
return False
log , errs = addonsManager . processPackages ( paths , parent = parent )
if log :
log_html = " <br> " . join ( log )
2020-01-04 04:45:43 +01:00
if advise_restart :
log_html + = " <br><br> " + _ (
" <b>Please restart Anki to complete the installation.</b> "
)
2020-01-04 04:34:16 +01:00
if len ( log ) == 1 and not strictly_modal :
2020-01-03 17:57:33 +01:00
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