parent
a94819a233
commit
263c9092b5
|
@ -0,0 +1,172 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<!-- interface-requires gtk+ 2.12 -->
|
||||
<object class="GtkDialog" id="gPodderExtensionManager">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="title" translatable="yes">Extension Manager</property>
|
||||
<property name="modal">True</property>
|
||||
<property name="window_position">center-on-parent</property>
|
||||
<property name="default_width">700</property>
|
||||
<property name="default_height">400</property>
|
||||
<property name="type_hint">dialog</property>
|
||||
<child internal-child="vbox">
|
||||
<object class="GtkBox" id="vbox_extension">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkVBox" id="vbox_for_extension_selection">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="border_width">5</property>
|
||||
<property name="spacing">5</property>
|
||||
<child>
|
||||
<object class="GtkLabel" id="labelInstructions">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="label">Choose which plugins should be loaded at startup:</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow" id="scrolledwindow2">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<child>
|
||||
<object class="GtkTreeView" id="treeviewExtensions">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="reorderable">True</property>
|
||||
<property name="enable_search">False</property>
|
||||
<property name="search_column">0</property>
|
||||
<signal name="row-activated" handler="on_row_activated" swapped="no"/>
|
||||
<child internal-child="selection">
|
||||
<object class="GtkTreeSelection" id="treeview-selection1">
|
||||
<signal name="changed" handler="on_selection_changed" swapped="no"/>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkHBox" id="hbox1">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="hexpand">True</property>
|
||||
<child>
|
||||
<object class="GtkStatusbar" id="extension_statusbar">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkHButtonBox" id="hbox_plugin_buttons">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="halign">end</property>
|
||||
<property name="layout_style">end</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnExtensionPrefs">
|
||||
<property name="label">gtk-properties</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="use_action_appearance">False</property>
|
||||
<property name="use_stock">True</property>
|
||||
<signal name="clicked" handler="on_btnExtensionPrefs_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkHSeparator" id="separator1">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">3</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child internal-child="action_area">
|
||||
<object class="GtkButtonBox" id="hbox_main_buttons">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnOK">
|
||||
<property name="label">gtk-ok</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="has_focus">True</property>
|
||||
<property name="can_default">True</property>
|
||||
<property name="has_default">True</property>
|
||||
<property name="receives_default">False</property>
|
||||
<property name="use_action_appearance">False</property>
|
||||
<property name="use_stock">True</property>
|
||||
<signal name="clicked" handler="on_btnOK_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">False</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">2</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<action-widgets>
|
||||
<action-widget response="0">btnOK</action-widget>
|
||||
</action-widgets>
|
||||
</object>
|
||||
</interface>
|
|
@ -0,0 +1,97 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<!-- interface-requires gtk+ 3.0 -->
|
||||
<object class="GtkDialog" id="gPodderExtensionPreference">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="border_width">5</property>
|
||||
<property name="default_width">400</property>
|
||||
<property name="default_height">400</property>
|
||||
<property name="type_hint">dialog</property>
|
||||
<property name="has_resize_grip">False</property>
|
||||
<child internal-child="vbox">
|
||||
<object class="GtkBox" id="vbox_extensionpref">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">2</property>
|
||||
<child internal-child="action_area">
|
||||
<object class="GtkButtonBox" id="buttons_extensionpref">
|
||||
<property name="can_focus">False</property>
|
||||
<property name="layout_style">end</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnRevert">
|
||||
<property name="label">gtk-revert-to-saved</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="receives_default">True</property>
|
||||
<property name="has_tooltip">True</property>
|
||||
<property name="tooltip_text" translatable="yes">Revert to default settings</property>
|
||||
<property name="use_action_appearance">False</property>
|
||||
<property name="use_stock">True</property>
|
||||
<signal name="clicked" handler="on_btnRevert_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="btnClose">
|
||||
<property name="label">gtk-close</property>
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="has_focus">True</property>
|
||||
<property name="can_default">True</property>
|
||||
<property name="has_default">True</property>
|
||||
<property name="receives_default">False</property>
|
||||
<property name="use_action_appearance">False</property>
|
||||
<property name="use_stock">True</property>
|
||||
<signal name="clicked" handler="on_btnClose_clicked" swapped="no"/>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">False</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="pack_type">end</property>
|
||||
<property name="position">0</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkScrolledWindow" id="scroll_extensionpref">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">True</property>
|
||||
<property name="hscrollbar_policy">never</property>
|
||||
<property name="shadow_type">in</property>
|
||||
<child>
|
||||
<object class="GtkViewport" id="viewport_extensionpref">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="shadow_type">none</property>
|
||||
<child>
|
||||
<placeholder/>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="expand">True</property>
|
||||
<property name="fill">True</property>
|
||||
<property name="position">1</property>
|
||||
</packing>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<action-widgets>
|
||||
<action-widget response="0">btnRevert</action-widget>
|
||||
<action-widget response="0">btnClose</action-widget>
|
||||
</action-widgets>
|
||||
</object>
|
||||
</interface>
|
|
@ -0,0 +1,478 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# gPodder - A media aggregator and podcast client
|
||||
# Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
|
||||
#
|
||||
# gPodder is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# gPodder is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Loads and executes user extentions.
|
||||
|
||||
Extentions are python scripts in the "Extensions" folder of $GPODDER_HOME. Each script
|
||||
must define a class named "gPodderExtensions", otherwise it will be ignored.
|
||||
|
||||
The extentions class defines several callbacks that will be called by the
|
||||
gPodder application at certain points. See the methods defined below
|
||||
for a list on what these callbacks are and the parameters they take.
|
||||
|
||||
For an example extension see examples/extensions.py
|
||||
"""
|
||||
|
||||
import glob
|
||||
import imp
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import functools
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import gpodder
|
||||
from gpodder import util
|
||||
from gpodder.jsonconfig import JsonConfig
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The class name that has to appear in a extension module
|
||||
EXTENSION_CLASS = 'gPodderExtension'
|
||||
|
||||
# The variable name that stores the extensions parameters
|
||||
EXTENSION_PARAMS = 'PARAMS'
|
||||
|
||||
# The variable name that stores the extensions parameters
|
||||
EXTENSION_CONFIG = 'DEFAULT_CONFIG'
|
||||
|
||||
EXTENSION_FOLDER = 'gpodder_extensions'
|
||||
FILE_EXTENSION_NAME = 'extension.py'
|
||||
FILE_META_NAME = 'metadata.json'
|
||||
|
||||
|
||||
def call_extensions(func):
|
||||
"""Decorator to create handler functions in ExtensionManager
|
||||
|
||||
Calls the specified function in all user extensions that define it.
|
||||
"""
|
||||
method_name = func.__name__
|
||||
|
||||
@functools.wraps(func)
|
||||
def handler(self, *args, **kwargs):
|
||||
result = None
|
||||
for extension_consumer, state in self.modules:
|
||||
if state:
|
||||
try:
|
||||
callback = getattr(extension_consumer.module, method_name, None)
|
||||
if callback is not None:
|
||||
# If the results are lists, concatenate them to show all
|
||||
# possible items that are generated by all extension together
|
||||
cb_res = callback(*args, **kwargs)
|
||||
if isinstance(result, list) and isinstance(cb_res, list):
|
||||
result.extend(cb_res)
|
||||
elif cb_res is not None:
|
||||
result = cb_res
|
||||
except Exception, e:
|
||||
logger.error('Error in %s, function %s: %s', extension_consumer.extension_file,
|
||||
method_name, e, exc_info=True)
|
||||
func(self, *args, **kwargs)
|
||||
return result
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
class ExtensionParent(object):
|
||||
"""Super class for every extension"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.metadata = kwargs.get('metadata', None)
|
||||
self.config = kwargs.get('config', None)
|
||||
self.context_menu_callback = None
|
||||
self.notify = gpodder.notify
|
||||
self.read_metadata()
|
||||
|
||||
# this code is needed when running the extensions unittests
|
||||
if isinstance(self.config, dict) and self.metadata:
|
||||
self.config = JsonConfig(data=json.dumps(self.config))
|
||||
self.config = getattr(self.config.extensions, self.metadata['id'])
|
||||
|
||||
def read_metadata(self):
|
||||
self.id = None
|
||||
self.name = None
|
||||
self.desc = None
|
||||
self.authors = []
|
||||
|
||||
if self.metadata is not None:
|
||||
if self.metadata.has_key('id'):
|
||||
self.id = self.metadata['id']
|
||||
|
||||
if self.metadata.has_key('name'):
|
||||
self.name = self.metadata['name']
|
||||
|
||||
if self.metadata.has_key('desc'):
|
||||
self.desc = self.metadata['desc']
|
||||
|
||||
if self.metadata.has_key('authors'):
|
||||
self.desc = self.metadata['authors']
|
||||
|
||||
def check_command(self, cmd):
|
||||
"""Check if a command line command/program exists"""
|
||||
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
# Prior to Python 2.7.3, this module (shlex) did not support Unicode input.
|
||||
if isinstance(cmd, unicode):
|
||||
cmd = cmd.encode('ascii', 'ignore')
|
||||
|
||||
program = shlex.split(cmd)[0]
|
||||
try:
|
||||
subprocess.Popen(shlex.split('%s --version' % program),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
except Exception as (errno, errstr):
|
||||
raise ImportError('%s: %s' % (errstr, program))
|
||||
|
||||
def notify_action(self, action, episode):
|
||||
"""method to simple use the notification system"""
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
name = 'gPodder-Extension'
|
||||
if self.name is not None:
|
||||
name = '%s: %s' % (name, self.name)
|
||||
|
||||
self.notify.message(name, "%s(%s): '%s/%s'" % (action,
|
||||
now.strftime('%x %X'), episode.channel.title, episode.title))
|
||||
|
||||
def _show_context_menu(self, episodes):
|
||||
"""return if a context menu entry should be displayed"""
|
||||
return False
|
||||
|
||||
def on_episodes_context_menu(self, episodes):
|
||||
"""add context menu entry for a specific extension"""
|
||||
|
||||
if self.name is None:
|
||||
return None
|
||||
|
||||
if not self._show_context_menu(episodes) or self.context_menu_callback is None:
|
||||
return None
|
||||
|
||||
return [(self.name, self.context_menu_callback)]
|
||||
|
||||
def update_episode_file(self, episode, filename):
|
||||
"""method for simple update an episode with filename information"""
|
||||
|
||||
if not os.path.exists(filename):
|
||||
return
|
||||
|
||||
basename, extension = os.path.splitext(filename)
|
||||
|
||||
episode.download_filename = os.path.basename(filename)
|
||||
episode.file_size = os.path.getsize(filename)
|
||||
episode.mime_type = util.mimetype_from_extension(extension)
|
||||
episode.save()
|
||||
episode.db.commit()
|
||||
|
||||
|
||||
class ExtensionConsumer(object):
|
||||
"""A class which manage one extension
|
||||
|
||||
it loads the meta information from metadata.json,
|
||||
it loads the extension class from extension.py
|
||||
and it loads default configuration
|
||||
"""
|
||||
|
||||
def __init__(self, config=None, path=None, module=None):
|
||||
self.module = module
|
||||
self._gpo_config = config
|
||||
self.config = None
|
||||
self.extension_file = None
|
||||
self.metadata = None
|
||||
self.params = None
|
||||
|
||||
if path is not None and module is None:
|
||||
self.extension_file = os.path.join(path, FILE_EXTENSION_NAME)
|
||||
metadata_file = os.path.join(path, FILE_META_NAME)
|
||||
self.metadata = self._load_metadata(metadata_file)
|
||||
|
||||
elif path is None and module is not None:
|
||||
pass
|
||||
|
||||
else:
|
||||
logger.error("ExtensionConsumer couldn't initialize successfully")
|
||||
|
||||
def _load_module(self, filepath):
|
||||
path, filename = os.path.split(filepath)
|
||||
package_name = os.path.split(path)[1]
|
||||
module_name, module_extension = os.path.splitext(filename)
|
||||
|
||||
pkg_file, pkg_path, pkg_desc = imp.find_module(package_name, [ os.path.split(path)[0] ])
|
||||
pkg = imp.load_module(package_name, None, pkg_path, pkg_desc)
|
||||
|
||||
module_file, module_path, module_desc = imp.find_module(module_name, pkg.__path__)
|
||||
return imp.load_module('%s.%s' % (package_name, module_name),
|
||||
file(filepath, 'r'), module_path, module_desc)
|
||||
|
||||
def _load_metadata(self, metadata_file):
|
||||
with open(metadata_file, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
return metadata
|
||||
|
||||
def _load_user_prefs(self, module_file):
|
||||
if not self.metadata['id'] in self._gpo_config.extensions.keys():
|
||||
config = getattr(module_file, EXTENSION_CONFIG, None)
|
||||
if config is not None:
|
||||
self._gpo_config.register_defaults(config)
|
||||
|
||||
return getattr(self._gpo_config.extensions, self.metadata['id'])
|
||||
|
||||
def load_extension(self):
|
||||
"""Load a Python module by filename
|
||||
|
||||
Returns an instance of the EXTENSION_CLASS class defined
|
||||
in the module, or None if the module does not contain
|
||||
such a class.
|
||||
"""
|
||||
try:
|
||||
module_file = self._load_module(self.extension_file)
|
||||
self.config = self._load_user_prefs(module_file)
|
||||
self.params = getattr(module_file, EXTENSION_PARAMS, None)
|
||||
|
||||
extension_class = getattr(module_file, EXTENSION_CLASS, None)
|
||||
self.module = extension_class(
|
||||
metadata=self.metadata,
|
||||
config=self.config
|
||||
)
|
||||
|
||||
logger.info('Module loaded: %s', self.extension_file)
|
||||
return None
|
||||
except Exception, e:
|
||||
logger.error('Cannot load %s: %s', self.extension_file, e, exc_info=True)
|
||||
return e
|
||||
|
||||
def revert_settings(self):
|
||||
"""revert stored extension settings to the default values"""
|
||||
|
||||
if self.metadata['id'] in self._gpo_config.extensions.keys():
|
||||
del self._gpo_config.extensions[self.metadata['id']]
|
||||
|
||||
module_file = self._load_module(self.extension_file)
|
||||
|
||||
config = getattr(module_file, EXTENSION_CONFIG, None)
|
||||
if config is not None:
|
||||
self._gpo_config.register_defaults(config)
|
||||
self._gpo_config.save()
|
||||
|
||||
|
||||
class ExtensionManager(object):
|
||||
"""Manager class for the extensions
|
||||
|
||||
This class loads all available extensions from the filesystem
|
||||
it also holds all "hook"-methods which can be used in an
|
||||
extension
|
||||
|
||||
"""
|
||||
DISABLED, ENABLED = range(2)
|
||||
EXTENSIONCONSUMER, STATE = range(2)
|
||||
|
||||
def __init__(self, config):
|
||||
extension_root_path = self._get_extension_root_path()
|
||||
if extension_root_path is None:
|
||||
return
|
||||
|
||||
self.modules = []
|
||||
enabled_extensions = []
|
||||
self._config = config
|
||||
enabled_extensions = self._config.extensions.enabled
|
||||
|
||||
pathname = os.path.join(extension_root_path, '*/')
|
||||
for extension_path in glob.glob(pathname):
|
||||
extension_consumer = ExtensionConsumer(
|
||||
config=self._config, path=extension_path)
|
||||
|
||||
state = self.DISABLED
|
||||
extension_id = extension_consumer.metadata['id']
|
||||
if extension_id in enabled_extensions:
|
||||
error = extension_consumer.load_extension()
|
||||
if error is None:
|
||||
state = self.ENABLED
|
||||
else:
|
||||
state = self.DISABLED
|
||||
enabled_extensions.remove(extension_id)
|
||||
|
||||
self.modules.append((extension_consumer, state, ))
|
||||
|
||||
def register_extensions(self, obj):
|
||||
"""Register an object that implements some extensions."""
|
||||
|
||||
self.modules.append((ExtensionConsumer(module=obj), self.ENABLED))
|
||||
|
||||
def unregister_extensions(self, obj):
|
||||
"""Unregister a previously registered object."""
|
||||
|
||||
extension_module = (ExtensionConsumer(module=obj), self.ENABLED)
|
||||
if extension_module in self.modules:
|
||||
self.modules.remove(extension_module)
|
||||
else:
|
||||
logger.warn('Unregistered extension which was not registered.')
|
||||
|
||||
def _get_extension_root_path(self):
|
||||
"""returns the file-system path where the extensions are stored"""
|
||||
|
||||
extension_root_path = os.environ.get('GPODDER_EXTENSIONS', None)
|
||||
if extension_root_path is None:
|
||||
gpo_folder = os.path.dirname(gpodder.__file__)
|
||||
extension_root_path = os.path.join(gpo_folder, EXTENSION_FOLDER)
|
||||
|
||||
if extension_root_path is not None:
|
||||
sys.path.append(extension_root_path)
|
||||
|
||||
logger.info('Reading extension script from %s', extension_root_path)
|
||||
return extension_root_path
|
||||
|
||||
def get_extensions(self):
|
||||
"""returns a list of all loaded extensions with the enabled/disable state"""
|
||||
|
||||
enabled_extensions = self._config.extensions.enabled
|
||||
for index, (extension_consumer, enabled) in enumerate(self.modules):
|
||||
if extension_consumer.metadata:
|
||||
if extension_consumer.metadata['id'] in enabled_extensions:
|
||||
enabled = self.ENABLED
|
||||
else:
|
||||
enabled = self.DISABLED
|
||||
self.modules[index] = (extension_consumer, enabled, )
|
||||
|
||||
return self.modules
|
||||
|
||||
# Define all known handler functions here, decorate them with the
|
||||
# "call_extension" decorator to forward all calls to extension scripts that have
|
||||
# the same function defined in them. If the handler functions here contain
|
||||
# any code, it will be called after all the extensions have been called.
|
||||
|
||||
@call_extensions
|
||||
def on_ui_initialized(self, model, update_podcast_callback,
|
||||
download_episode_callback):
|
||||
"""Called when the user interface is initialized.
|
||||
|
||||
@param model: A gpodder.model.Model instance
|
||||
@param update_podcast_callback: Function to update a podcast feed
|
||||
@param download_episode_callback: Function to download an episode
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_podcast_subscribe(self, podcast):
|
||||
"""Called when the user subscribes to a new podcast feed.
|
||||
|
||||
@param podcast: A gpodder.model.PodcastChannel instance
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_podcast_updated(self, podcast):
|
||||
"""Called when a podcast feed was updated
|
||||
|
||||
This extension will be called even if there were no new episodes.
|
||||
|
||||
@param podcast: A gpodder.model.PodcastChannel instance
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_podcast_update_failed(self, podcast, exception):
|
||||
"""Called when a podcast update failed.
|
||||
|
||||
@param podcast: A gpodder.model.PodcastChannel instance
|
||||
|
||||
@param exception: The reason.
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_podcast_save(self, podcast):
|
||||
"""Called when a podcast is saved to the database
|
||||
|
||||
This extensions will be called when the user edits the metadata of
|
||||
the podcast or when the feed was updated.
|
||||
|
||||
@param podcast: A gpodder.model.PodcastChannel instance
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_podcast_delete(self, podcast):
|
||||
"""Called when a podcast is deleted from the database
|
||||
|
||||
@param podcast: A gpodder.model.PodcastChannel instance
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_episode_save(self, episode):
|
||||
"""Called when an episode is saved to the database
|
||||
|
||||
This extension will be called when a new episode is added to the
|
||||
database or when the state of an existing episode is changed.
|
||||
|
||||
@param episode: A gpodder.model.PodcastEpisode instance
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_episode_downloaded(self, episode):
|
||||
"""Called when an episode has been downloaded
|
||||
|
||||
You can retrieve the filename via episode.local_filename(False)
|
||||
|
||||
@param episode: A gpodder.model.PodcastEpisode instance
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_all_episodes_downloaded(self):
|
||||
"""Called when all episodes has been downloaded
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_episodes_context_menu(self, episodes):
|
||||
"""Called when the episode list context menu is opened
|
||||
|
||||
You can add additional context menu entries here. You have to
|
||||
return a list of tuples, where the first item is a label and
|
||||
the second item is a callable that will get the episode as its
|
||||
first and only parameter.
|
||||
|
||||
Example return value:
|
||||
|
||||
[('Mark as new', lambda episodes: ...)]
|
||||
|
||||
@param episodes: A list of gpodder.model.PodcastEpisode instances
|
||||
"""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_episode_delete(self, episode, filename):
|
||||
"""Called just before the episode's disk file is about to be
|
||||
deleted."""
|
||||
pass
|
||||
|
||||
@call_extensions
|
||||
def on_episode_removed_from_podcast(self, episode):
|
||||
"""Called just before the episode is about to be removed from
|
||||
the podcast channel, e.g., when the episode has not been
|
||||
downloaded and it disappears from the feed.
|
||||
|
||||
@param podcast: A gpodder.model.PodcastChannel instance
|
||||
"""
|
||||
pass
|
|
@ -0,0 +1,415 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# gPodder - A media aggregator and podcast client
|
||||
# Copyright (c) 2005-2011 Thomas Perl and the gPodder Team
|
||||
#
|
||||
# gPodder is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# gPodder is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import gobject
|
||||
import gtk
|
||||
import pango
|
||||
import os.path
|
||||
|
||||
import gpodder
|
||||
|
||||
_ = gpodder.gettext
|
||||
N_ = gpodder.ngettext
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from gpodder.gtkui.interface.common import BuilderWidget
|
||||
|
||||
|
||||
class ExtensionTextItem(gtk.VBox):
|
||||
def __init__(self, key, settings, value):
|
||||
gtk.VBox.__init__(self, spacing=6)
|
||||
self.set_border_width(6)
|
||||
self.key = key
|
||||
|
||||
self.__label = gtk.Label(settings['desc'])
|
||||
self.__label.set_alignment(0, 0)
|
||||
|
||||
self.__entry = gtk.Entry(max=0)
|
||||
self.__entry.set_visibility(True)
|
||||
self.__entry.set_editable(True)
|
||||
self.__entry.set_text(value)
|
||||
self.pack_start(self.__label, False, False, 0)
|
||||
self.pack_start(self.__entry, False, False, 0)
|
||||
|
||||
def get_key(self):
|
||||
return self.key
|
||||
|
||||
def get_value(self):
|
||||
return self.__entry.get_text()
|
||||
|
||||
def set_value(self, settings, value):
|
||||
self.__entry.set_text(value)
|
||||
|
||||
|
||||
class ExtensionCheckBox(gtk.CheckButton):
|
||||
def __init__(self, key, settings, value):
|
||||
gtk.CheckButton.__init__(self)
|
||||
self.set_border_width(6)
|
||||
self.key = key
|
||||
|
||||
self.set_label(settings['desc'])
|
||||
self.set_active(value)
|
||||
|
||||
def get_key(self):
|
||||
return self.key
|
||||
|
||||
def get_value(self):
|
||||
return self.get_active()
|
||||
|
||||
def set_value(self, settings, value):
|
||||
self.set_active(value)
|
||||
|
||||
|
||||
class ExtensionSpinButton(gtk.VBox):
|
||||
def __init__(self, key, settings, value):
|
||||
gtk.VBox.__init__(self, spacing=6)
|
||||
self.set_border_width(6)
|
||||
self.key = key
|
||||
|
||||
self.__label = gtk.Label(settings['desc'])
|
||||
self.__label.set_alignment(0, 0)
|
||||
|
||||
self.__spin = gtk.SpinButton(
|
||||
adjustment=gtk.Adjustment(value, 0, 1000, 1),
|
||||
climb_rate=1, digits=2
|
||||
)
|
||||
|
||||
self.pack_start(self.__label, False, False, 0)
|
||||
self.pack_start(self.__spin, False, False, 0)
|
||||
|
||||
def get_key(self):
|
||||
return self.key
|
||||
|
||||
def get_value(self):
|
||||
return self.__spin.get_value()
|
||||
|
||||
def set_value(self, settings, value):
|
||||
self.__spin.set_value(value)
|
||||
|
||||
|
||||
class ExtensionMultiChoice(gtk.VBox):
|
||||
def __init__(self, key, settings, value):
|
||||
gtk.VBox.__init__(self, spacing=6)
|
||||
self.set_border_width(6)
|
||||
self.key = key
|
||||
|
||||
self.__label = gtk.Label(settings['desc'])
|
||||
self.__label.set_alignment(0, 0)
|
||||
|
||||
self.__treeView = gtk.TreeView(self._get_model(settings, value))
|
||||
|
||||
toggle_cell = gtk.CellRendererToggle()
|
||||
toggle_cell.connect('toggled', self.value_changed, self.__treeView)
|
||||
toggle_column = gtk.TreeViewColumn('', toggle_cell, active=0)
|
||||
toggle_column.set_clickable(True)
|
||||
self.__treeView.append_column(toggle_column)
|
||||
|
||||
renderer = gtk.CellRendererText()
|
||||
renderer.set_property('ellipsize', pango.ELLIPSIZE_END)
|
||||
column = gtk.TreeViewColumn(_('Podcast'), renderer, markup=1)
|
||||
column.set_clickable(False)
|
||||
column.set_resizable(True)
|
||||
column.set_expand(True)
|
||||
self.__treeView.append_column(column)
|
||||
self.__treeView.columns_autosize()
|
||||
|
||||
self.pack_start(self.__label, False, False, 0)
|
||||
self.pack_start(self.__treeView, False, False, 0)
|
||||
|
||||
def _get_model(self, settings, value):
|
||||
model = gtk.ListStore(
|
||||
gobject.TYPE_BOOLEAN, gobject.TYPE_STRING, gobject.TYPE_INT
|
||||
)
|
||||
|
||||
multichoice_list = zip(value, settings['list'])
|
||||
for index, (state, text) in enumerate(multichoice_list):
|
||||
model.append(row=(state, text, index))
|
||||
return model
|
||||
|
||||
def value_changed(self, cell, path, treeview):
|
||||
model = treeview.get_model()
|
||||
iter = model.get_iter(path)
|
||||
value = model.get_value(iter, 0)
|
||||
model.set_value(iter, 0, not value)
|
||||
|
||||
def get_key(self):
|
||||
return self.key
|
||||
|
||||
def get_value(self):
|
||||
model = self.__treeView.get_model()
|
||||
result = []
|
||||
iter = model.get_iter_first()
|
||||
while iter is not None:
|
||||
value = model.get_value(iter, 0)
|
||||
result.append(value)
|
||||
iter = model.iter_next(iter)
|
||||
return result
|
||||
|
||||
def set_value(self, settings, value):
|
||||
model = self._get_model(settings, value)
|
||||
self.__treeView.set_model(model)
|
||||
|
||||
|
||||
class ExtensionRadioGroup(gtk.VBox):
|
||||
def __init__(self, key, settings, value):
|
||||
gtk.VBox.__init__(self, spacing=6)
|
||||
self.set_border_width(6)
|
||||
self.key = key
|
||||
|
||||
self.__label = gtk.Label(settings['desc'])
|
||||
self.__label.set_alignment(0, 0)
|
||||
|
||||
self.__radioButtons = []
|
||||
choices = zip(value, settings['list'])
|
||||
group = None
|
||||
for state, label in choices:
|
||||
rb = gtk.RadioButton(group, label, False)
|
||||
rb.set_active(state)
|
||||
group = rb
|
||||
self.__radioButtons.append(rb)
|
||||
|
||||
self.pack_start(self.__label, False, False, 0)
|
||||
for rb in self.__radioButtons:
|
||||
self.pack_start(rb, False, False, 0)
|
||||
|
||||
def get_key(self):
|
||||
return self.key
|
||||
|
||||
def get_value(self):
|
||||
values = []
|
||||
for rb in self.__radioButtons:
|
||||
values.append(rb.get_active())
|
||||
return values
|
||||
|
||||
def set_value(self, settings, value):
|
||||
choices = zip(value, self.__radioButtons)
|
||||
for state, rb in choices:
|
||||
rb.set_active(state)
|
||||
|
||||
|
||||
#TODO: Replace this implementation with a ComboBoxEntry or expandable TreeView
|
||||
class ExtensionComboBoxEntry(ExtensionTextItem):
|
||||
def __init__(self, key, settings, value):
|
||||
value = ';'.join(value)
|
||||
ExtensionTextItem.__init__(self, key, settings, value)
|
||||
|
||||
def get_value(self):
|
||||
value = super(ExtensionComboBoxEntry, self).get_value()
|
||||
return value.split(';')
|
||||
|
||||
def set_value(self, settings, value):
|
||||
value = ';'.join(value)
|
||||
super(ExtensionComboBoxEntry, self).set_value(settings, value)
|
||||
|
||||
|
||||
class gPodderExtensionPreference(BuilderWidget):
|
||||
widgets = { 'textitem': ExtensionTextItem,
|
||||
'checkbox': ExtensionCheckBox,
|
||||
'spinbutton': ExtensionSpinButton,
|
||||
'multichoice-list': ExtensionMultiChoice,
|
||||
'combobox': ExtensionComboBoxEntry,
|
||||
'radiogroup': ExtensionRadioGroup,
|
||||
}
|
||||
|
||||
def new(self):
|
||||
"""Extension Preference Dialog
|
||||
|
||||
Optional keyword arguments that modify the behaviour of this dialog:
|
||||
|
||||
- _extenstion_consumer: ExtensionConsumer class for which the preferences should be displayed
|
||||
{'cmd': {
|
||||
'type': 'str',
|
||||
'desc': 'Defines the command line bittorrent program'}
|
||||
}
|
||||
"""
|
||||
self.params = self._extension_consumer.params
|
||||
self.config = self._extension_consumer.config
|
||||
|
||||
self.vbox = gtk.VBox()
|
||||
self.viewport_extensionpref.add(self.vbox)
|
||||
for key, settings in self.params.items():
|
||||
value = getattr(self._extension_consumer.config, key)
|
||||
widget = self.widgets[settings['type']](key, settings, value)
|
||||
self.vbox.pack_start(widget, False, False, 0)
|
||||
|
||||
self.gPodderExtensionPreference.show_all()
|
||||
|
||||
def on_btnRevert_clicked(self, widget):
|
||||
self._extension_consumer.revert_settings()
|
||||
self.config = self._extension_consumer.config
|
||||
|
||||
for w in self.vbox.get_children():
|
||||
key = w.get_key()
|
||||
value = getattr(self._extension_consumer.config, key)
|
||||
w.set_value(self.params[key], value)
|
||||
|
||||
def on_btnClose_clicked(self, widget):
|
||||
for w in self.vbox.get_children():
|
||||
key = w.get_key()
|
||||
value = w.get_value()
|
||||
setattr(self.config, key, value)
|
||||
|
||||
self.main_window.destroy()
|
||||
|
||||
|
||||
class gPodderExtensionManager(BuilderWidget):
|
||||
C_INDEX, C_TOOLTIP, C_TOGGLE, C_NAME, C_ID, C_EXTENSIONCONSUMER = range(6)
|
||||
|
||||
def new(self):
|
||||
toggle_cell = gtk.CellRendererToggle()
|
||||
toggle_cell.connect('toggled', self.toggle_cell_handler)
|
||||
toggle_column = gtk.TreeViewColumn('', toggle_cell, active=self.C_TOGGLE)
|
||||
toggle_column.set_clickable(True)
|
||||
self.treeviewExtensions.append_column(toggle_column)
|
||||
|
||||
renderer = gtk.CellRendererText()
|
||||
renderer.set_property('ellipsize', pango.ELLIPSIZE_END)
|
||||
column = gtk.TreeViewColumn(_('Name'), renderer, markup=self.C_NAME)
|
||||
column.set_clickable(False)
|
||||
column.set_resizable(True)
|
||||
column.set_expand(True)
|
||||
self.treeviewExtensions.append_column(column)
|
||||
|
||||
renderer = gtk.CellRendererText()
|
||||
column = gtk.TreeViewColumn(_('Extension-ID'), renderer, markup=self.C_ID)
|
||||
column.set_clickable(False)
|
||||
column.set_resizable(True)
|
||||
column.set_expand(False)
|
||||
self.treeviewExtensions.append_column(column)
|
||||
|
||||
self.treeviewExtensions.set_property('has-tooltip', True)
|
||||
self.treeviewExtensions.connect('query-tooltip', self.treeview_show_tooltip)
|
||||
|
||||
column_types = [ gobject.TYPE_INT,
|
||||
gobject.TYPE_STRING,
|
||||
gobject.TYPE_BOOLEAN,
|
||||
gobject.TYPE_STRING,
|
||||
gobject.TYPE_STRING,
|
||||
gobject.TYPE_PYOBJECT ]
|
||||
self.model = gtk.ListStore(*column_types)
|
||||
|
||||
for index, (extension_consumer, state) in enumerate( gpodder.user_extensions.get_extensions() ):
|
||||
if extension_consumer.extension_file is not None:
|
||||
tooltip = extension_consumer.metadata['desc']
|
||||
name = extension_consumer.metadata['name']
|
||||
row = [ index, tooltip, state, name,
|
||||
extension_consumer.metadata['id'], extension_consumer
|
||||
]
|
||||
self.model.append(row)
|
||||
|
||||
self.model.set_sort_column_id(self.C_NAME, gtk.SORT_ASCENDING)
|
||||
self.treeviewExtensions.set_model(self.model)
|
||||
self.treeviewExtensions.columns_autosize()
|
||||
|
||||
self.context_id = self.extension_statusbar.get_context_id('Extension messages')
|
||||
|
||||
def _set_enabled_extension_in_config(self, model, path):
|
||||
iter = model.get_iter(path)
|
||||
value = model.get_value(iter, self.C_TOGGLE)
|
||||
name = model.get_value(iter, self.C_NAME)
|
||||
extension_id = model.get_value(iter, self.C_ID)
|
||||
extension_consumer = model.get_value(iter, self.C_EXTENSIONCONSUMER)
|
||||
new_value = not value
|
||||
|
||||
if new_value and extension_id not in self._config.extensions.enabled:
|
||||
error = extension_consumer.load_extension()
|
||||
if error is None:
|
||||
self._config.extensions.enabled.append(extension_id)
|
||||
self.extension_statusbar.remove_all(self.context_id)
|
||||
else:
|
||||
self.extension_statusbar.push(self.context_id, "Error %s: %s" % (name, error))
|
||||
return
|
||||
|
||||
if not new_value and extension_id in self._config.extensions.enabled:
|
||||
self._config.extensions.enabled.remove(extension_id)
|
||||
|
||||
self._config.schedule_save()
|
||||
self._set_preferences_button(not value)
|
||||
model.set_value(iter, self.C_TOGGLE, not value)
|
||||
|
||||
def _get_selected_extension_consumer(self):
|
||||
selection = self.treeviewExtensions.get_selection()
|
||||
model, iter = selection.get_selected()
|
||||
if not iter:
|
||||
return None
|
||||
|
||||
return model.get_value(iter, self.C_EXTENSIONCONSUMER)
|
||||
|
||||
def _set_preferences_button(self, value):
|
||||
extension = self._get_selected_extension_consumer()
|
||||
|
||||
if extension and extension.params is not None and value:
|
||||
self.btnExtensionPrefs.set_sensitive(True)
|
||||
else:
|
||||
self.btnExtensionPrefs.set_sensitive(False)
|
||||
|
||||
def on_button_close_clicked(self, widget):
|
||||
self.main_window.destroy()
|
||||
|
||||
def on_btnOK_clicked(self, widget):
|
||||
self.main_window.destroy()
|
||||
|
||||
def on_btnExtensionPrefs_clicked(self, widget):
|
||||
gPodderExtensionPreference(self.main_window,
|
||||
_extension_consumer = self._get_selected_extension_consumer()
|
||||
)
|
||||
|
||||
def toggle_cell_handler(self, cell, path):
|
||||
model = self.treeviewExtensions.get_model()
|
||||
self._set_enabled_extension_in_config(model, path)
|
||||
|
||||
def on_row_activated(self, treeview, path, view_column):
|
||||
model = treeview.get_model()
|
||||
self._set_enabled_extension_in_config(model, path)
|
||||
|
||||
def on_selection_changed(self, treeselection):
|
||||
model, iter = treeselection.get_selected()
|
||||
if not iter:
|
||||
value = False
|
||||
else:
|
||||
value = model.get_value(iter, self.C_TOGGLE)
|
||||
|
||||
self._set_preferences_button(value)
|
||||
|
||||
def treeview_show_tooltip(self, treeview, x, y, keyboard_tooltip, tooltip):
|
||||
# TODO: Copied some of the code from src/gpodder/gtkui/desktop/episodeselector.py (gPodderEpisodeSelector.treeview_episodes_query_tooltip)
|
||||
# maybe we should don't duplicate the code and implement this as a function globaly?!
|
||||
|
||||
# With get_bin_window, we get the window that contains the rows without
|
||||
# the header. The Y coordinate of this window will be the height of the
|
||||
# treeview header. This is the amount we have to subtract from the
|
||||
# event's Y coordinate to get the coordinate to pass to get_path_at_pos
|
||||
(x_bin, y_bin) = treeview.get_bin_window().get_position()
|
||||
y -= x_bin
|
||||
y -= y_bin
|
||||
(path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,)*4
|
||||
|
||||
if column != treeview.get_columns()[1]:
|
||||
return False
|
||||
|
||||
model = treeview.get_model()
|
||||
iter = model.get_iter(path)
|
||||
description = model.get_value(iter, self.C_TOOLTIP)
|
||||
if description:
|
||||
tooltip.set_text(description)
|
||||
return True
|
||||
else:
|
||||
return False
|
|
@ -0,0 +1,75 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# gPodder - A media aggregator and podcast client
|
||||
# Copyright (c) 2005-2011 Thomas Perl and the gPodder Team
|
||||
#
|
||||
# gPodder is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# gPodder is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
# gpodder.notify - Initialize the platforms notification system
|
||||
# Bernd Schlapsi <brot@gmx.info>; 2011-11-20
|
||||
|
||||
import gpodder
|
||||
import platform
|
||||
|
||||
class NotifyInterface(object):
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def message(self, title, message):
|
||||
pass
|
||||
|
||||
|
||||
class NotifyPyNotify(object):
|
||||
def __init__(self, config):
|
||||
import pynotify
|
||||
pynotify.init('gPodder')
|
||||
self.pynotify = pynotify
|
||||
|
||||
self.config = config
|
||||
|
||||
def message(self, title, message):
|
||||
if not self.pynotify.is_initted():
|
||||
return
|
||||
|
||||
if self.config is None or not self.config.enable_notifications:
|
||||
return
|
||||
|
||||
notification = self.pynotify.Notification(title, message,
|
||||
gpodder.icon_file)
|
||||
try:
|
||||
notification.show()
|
||||
except:
|
||||
# See http://gpodder.org/bug/966
|
||||
pass
|
||||
|
||||
|
||||
def init_notify(config):
|
||||
system = platform.system()
|
||||
if system == 'Linux':
|
||||
try:
|
||||
notify = NotifyPyNotify(config)
|
||||
except ImportError:
|
||||
# TODO: Notification class for harmattan?
|
||||
notify = NotifyInterface(config)
|
||||
|
||||
elif system == 'Windows':
|
||||
# TODO: Notification class for Windows (growl for windows?)
|
||||
notify = NotifyInterface(config)
|
||||
|
||||
elif system == 'Darwin':
|
||||
# TODO: Notification class for Mac (growl?)
|
||||
notify = NotifyInterface(config)
|
||||
|
||||
return notify
|
Loading…
Reference in New Issue