diff options
Diffstat (limited to 'src')
47 files changed, 3382 insertions, 1876 deletions
diff --git a/src/args.py b/src/args.py index 6b0108f0..8b1ebbbd 100644 --- a/src/args.py +++ b/src/args.py @@ -3,41 +3,26 @@ Module related to the argument parsing There is a fallback to the deprecated optparse if argparse is not found """ -from gettext import gettext as _ from os import path +from argparse import ArgumentParser, SUPPRESS def parse_args(CONFIG_PATH=''): """ Parse the arguments from the command line """ - try: - from argparse import ArgumentParser, SUPPRESS - except ImportError: - from optparse import OptionParser - from optparse import SUPPRESS_HELP as SUPPRESS - parser = OptionParser() - parser.add_option("-f", "--file", dest="filename", - default=path.join(CONFIG_PATH, 'poezio.cfg'), - help=_("The config file you want to use"), - metavar="CONFIG_FILE") - parser.add_option("-d", "--debug", dest="debug", - help=_("The file where debug will be written"), - metavar="DEBUG_FILE") - parser.add_option("-v", "--version", dest="version", - help=SUPPRESS, metavar="VERSION", - default="0.8.3-dev") - (options, __) = parser.parse_args() - else: - parser = ArgumentParser() - parser.add_argument("-f", "--file", dest="filename", - default=path.join(CONFIG_PATH, 'poezio.cfg'), - help=_("The config file you want to use"), - metavar="CONFIG_FILE") - parser.add_argument("-d", "--debug", dest="debug", - help=_("The file where debug will be written"), - metavar="DEBUG_FILE") - parser.add_argument("-v", "--version", dest="version", - help=SUPPRESS, metavar="VERSION", - default="0.8.3-dev") - options = parser.parse_args() + parser = ArgumentParser('poezio') + parser.add_argument("-c", "--check-config", dest="check_config", + action='store_true', + help='Check the config file') + parser.add_argument("-d", "--debug", dest="debug", + help="The file where debug will be written", + metavar="DEBUG_FILE") + parser.add_argument("-f", "--file", dest="filename", + default=path.join(CONFIG_PATH, 'poezio.cfg'), + help="The config file you want to use", + metavar="CONFIG_FILE") + parser.add_argument("-v", "--version", dest="version", + help=SUPPRESS, metavar="VERSION", + default="0.9-dev") + options = parser.parse_args() return options diff --git a/src/bookmark.py b/src/bookmark.py deleted file mode 100644 index 15a28c9d..00000000 --- a/src/bookmark.py +++ /dev/null @@ -1,250 +0,0 @@ -""" -Bookmarks module - -Therein the bookmark class is defined, representing one conference room. -This object is used to generate elements for both local and remote -bookmark storage. It can also parse xml Elements. - -This module also defines several functions for retrieving and updating -bookmarks, both local and remote. -""" - -import functools -import logging -from sys import version_info - -from slixmpp.plugins.xep_0048 import Bookmarks, Conference -from common import safeJID -from config import config - -log = logging.getLogger(__name__) - -def xml_iter(xml, tag=''): - if version_info[1] >= 2: - return xml.iter(tag) - else: - return xml.getiterator(tag) - -preferred = config.get('use_bookmarks_method').lower() -if preferred not in ('pep', 'privatexml'): - preferred = 'privatexml' -not_preferred = 'privatexml' if preferred == 'pep' else 'pep' -methods = ('local', preferred, not_preferred) - - -class Bookmark(object): - possible_methods = methods - - def __init__(self, jid, name=None, autojoin=False, nick=None, password=None, method='privatexml'): - self.jid = jid - self.name = name or jid - self.autojoin = autojoin - self.nick = nick - self.password = password - self._method = method - - @property - def method(self): - return self._method - - @method.setter - def method(self, value): - if value not in self.possible_methods: - log.debug('Could not set bookmark storing method: %s', value) - return - self._method = value - - def __repr__(self): - return '<%s%s%s>' % (self.jid, ('/'+self.nick) if self.nick else '', '|autojoin' if self.autojoin else '') - - def stanza(self): - """ - Generate a <conference/> stanza from the instance - """ - el = Conference() - el['name'] = self.name - el['jid'] = self.jid - el['autojoin'] = 'true' if self.autojoin else 'false' - if self.nick: - el['nick'] = self.nick - if self.password: - el['password'] = self.password - return el - - def local(self): - """Generate a str for local storage""" - local = self.jid - if self.nick: - local += '/%s' % self.nick - local += ':' - if self.password: - config.set_and_save('password', self.password, section=self.jid) - return local - - @staticmethod - def parse_from_element(el, method=None): - """ - Generate a Bookmark object from a <conference/> element - """ - jid = el.get('jid') - name = el.get('name') - autojoin = True if el.get('autojoin', 'false').lower() in ('true', '1') else False - nick = None - for n in xml_iter(el, 'nick'): - nick = n.text - password = None - for p in xml_iter(el, 'password'): - password = p.text - - return Bookmark(jid, name, autojoin, nick, password, method) - -bookmarks = [] - -def get_by_jid(value): - """ - Get a bookmark by bare jid - """ - for item in bookmarks: - if item.jid == value: - return item - -def remove(value): - """ - Remove a bookmark (with its jid or directly the Bookmark object). - """ - if isinstance(value, str): - value = get_by_jid(value) - bookmarks.remove(value) - -def stanza_storage(method): - """Generate a <storage/> stanza with the conference elements.""" - storage = Bookmarks() - for b in (b for b in bookmarks if b.method == method): - storage.append(b.stanza()) - return storage - -def save_pep(xmpp): - """Save the remote bookmarks via PEP.""" - xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage('pep'), - method='xep_0223') - -def save_privatexml(xmpp): - """"Save the remote bookmarks with privatexml.""" - xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage('privatexml'), - method='xep_0049') - -def save_remote(xmpp, callback, method=preferred): - """Save the remote bookmarks.""" - method = 'privatexml' if method != 'pep' else 'pep' - - if method is 'privatexml': - xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage('privatexml'), - method='xep_0049', - callback=callback) - else: - xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage('pep'), - method='xep_0223', - callback=callback) - -def save_local(): - """Save the local bookmarks.""" - local = ''.join(bookmark.local() for bookmark in bookmarks if bookmark.method is 'local') - config.set_and_save('rooms', local) - -def save(xmpp, core=None): - """Save all the bookmarks.""" - save_local() - def _cb(core, iq): - if iq["type"] == "error": - core.information('Could not save bookmarks.', 'Error') - elif core: - core.information('Bookmarks saved', 'Info') - if config.get('use_remote_bookmarks'): - preferred = config.get('use_bookmarks_method') - cb = functools.partial(_cb, core) - save_remote(xmpp, cb, method=preferred) - -def get_pep(xmpp, available_methods, callback): - """Add the remotely stored bookmarks via pep to the list.""" - def _cb(iq): - if iq["type"] == "error": - available_methods["pep"] = False - else: - available_methods["pep"] = True - for conf in xml_iter(iq.xml, '{storage:bookmarks}conference'): - b = Bookmark.parse_from_element(conf, method='pep') - if not get_by_jid(b.jid): - bookmarks.append(b) - if callback: - callback() - - xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223', callback=_cb) - -def get_privatexml(xmpp, available_methods, callback): - """Add the remotely stored bookmarks via privatexml to the list. - If both is True, we want to have the result of both methods (privatexml and pep) before calling pep""" - def _cb(iq): - if iq["type"] == "error": - available_methods["privatexml"] = False - else: - available_methods["privatexml"] = True - for conf in xml_iter(iq.xml, '{storage:bookmarks}conference'): - b = Bookmark.parse_from_element(conf, method='privatexml') - if not get_by_jid(b.jid): - bookmarks.append(b) - if callback: - callback() - - xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049', callback=_cb) - -def get_remote(xmpp, callback): - """Add the remotely stored bookmarks to the list.""" - if xmpp.anon: - return - method = config.get('use_bookmarks_method') - if not method: - available_methods = {} - def _save_and_call_callback(): - # If both methods returned a result, we can now call the given callback - if callback and "privatexml" in available_methods and "pep" in available_methods: - save_bookmarks_method(available_methods) - if callback: - callback() - for method in methods[1:]: - if method == 'pep': - get_pep(xmpp, available_methods, _save_and_call_callback) - else: - get_privatexml(xmpp, available_methods, _save_and_call_callback) - else: - if method == 'pep': - get_pep(xmpp, {}, callback) - else: - get_privatexml(xmpp, {}, callback) - -def save_bookmarks_method(available_methods): - pep, privatexml = available_methods["pep"], available_methods["privatexml"] - if pep and not privatexml: - config.set_and_save('use_bookmarks_method', 'pep') - elif privatexml and not pep: - config.set_and_save('use_bookmarks_method', 'privatexml') - elif not pep and not privatexml: - config.set_and_save('use_bookmarks_method', '') - -def get_local(): - """Add the locally stored bookmarks to the list.""" - rooms = config.get('rooms') - if not rooms: - return - rooms = rooms.split(':') - for room in rooms: - jid = safeJID(room) - if jid.bare == '': - continue - if jid.resource != '': - nick = jid.resource - else: - nick = None - passwd = config.get_by_tabname('password', jid.bare, fallback=False) or None - b = Bookmark(jid.bare, autojoin=True, nick=nick, password=passwd, method='local') - if not get_by_jid(b.jid): - bookmarks.append(b) diff --git a/src/bookmarks.py b/src/bookmarks.py new file mode 100644 index 00000000..c7d26a51 --- /dev/null +++ b/src/bookmarks.py @@ -0,0 +1,289 @@ +""" +Bookmarks module + +Therein the bookmark class is defined, representing one conference room. +This object is used to generate elements for both local and remote +bookmark storage. It can also parse xml Elements. + +This module also defines several functions for retrieving and updating +bookmarks, both local and remote. + +Poezio start scenario: + +- upon inital connection, poezio will disco#info the server +- the available storage methods will be stored in the available_storage dict + (either 'pep' or 'privatexml') +- if only one is available, poezio will set the use_bookmarks_method config option + to it. If both are, it will be set to 'privatexml' (or if it was previously set, the + value will be kept). +- it will then query the preferred storages for bookmarks and cache them locally + (Bookmark objects with a method='remote' attribute) + +Adding a remote bookmark: + +- New Bookmark object added to the list with storage='remote' +- All bookmarks are sent to the storage selected in use_bookmarks_method + if there was an error, the user is notified. + + +""" + +import functools +import logging + +from slixmpp.plugins.xep_0048 import Bookmarks, Conference, URL +from slixmpp import JID +from common import safeJID +from config import config + +log = logging.getLogger(__name__) + + +class Bookmark(object): + + def __init__(self, jid, name=None, autojoin=False, nick=None, password=None, method='local'): + self.jid = jid + self.name = name or jid + self.autojoin = autojoin + self.nick = nick + self.password = password + self._method = method + + @property + def method(self): + return self._method + + @method.setter + def method(self, value): + if value not in ('local', 'remote'): + log.debug('Could not set bookmark storing method: %s', value) + return + self._method = value + + def __repr__(self): + return '<%s%s|%s>' % (self.jid, + ('/'+self.nick) if self.nick else '', + self.method) + + def stanza(self): + """ + Generate a <conference/> stanza from the instance + """ + el = Conference() + el['name'] = self.name + el['jid'] = self.jid + el['autojoin'] = 'true' if self.autojoin else 'false' + if self.nick: + el['nick'] = self.nick + if self.password: + el['password'] = self.password + return el + + def local(self): + """Generate a str for local storage""" + local = self.jid + if self.nick: + local += '/%s' % self.nick + local += ':' + if self.password: + config.set_and_save('password', self.password, section=self.jid) + return local + + @functools.singledispatch + @staticmethod + def parse(el): + """ + Generate a Bookmark object from a <conference/> element + (this is a fallback for raw XML Elements) + """ + jid = el.get('jid') + name = el.get('name') + autojoin = True if el.get('autojoin', 'false').lower() in ('true', '1') else False + nick = None + for n in el.iter('nick'): + nick = n.text + password = None + for p in el.iter('password'): + password = p.text + + return Bookmark(jid, name, autojoin, nick, password, method='remote') + + @staticmethod + @parse.register(Conference) + def parse_from_stanza(el): + """ + Parse a Conference element into a Bookmark object + """ + jid = el['jid'] + autojoin = el['autojoin'] + password = el['password'] + nick = el['nick'] + name = el['name'] + return Bookmark(jid, name, autojoin, nick, password, method='remote') + +class BookmarkList(object): + + def __init__(self): + self.bookmarks = [] + preferred = config.get('use_bookmarks_method').lower() + if preferred not in ('pep', 'privatexml'): + preferred = 'privatexml' + self.preferred = preferred + self.available_storage = { + 'privatexml': False, + 'pep': False, + } + + def __getitem__(self, key): + if isinstance(key, (str, JID)): + for i in self.bookmarks: + if key == i.jid: + return i + else: + return self.bookmarks[key] + + def __in__(self, key): + if isinstance(key, (str, JID)): + for bookmark in self.bookmarks: + if bookmark.jid == key: + return True + else: + return key in self.bookmarks + return False + + def remove(self, key): + if isinstance(key, (str, JID)): + for i in self.bookmarks[:]: + if i.jid == key: + self.bookmarks.remove(i) + else: + self.bookmarks.remove(key) + + def __iter__(self): + return iter(self.bookmarks) + + def local(self): + return [bm for bm in self.bookmarks if bm.method == 'local'] + + def remote(self): + return [bm for bm in self.bookmarks if bm.method == 'remote'] + + def set(self, new): + self.bookmarks = new + + def append(self, bookmark): + bookmark_exists = self[bookmark.jid] + if not bookmark_exists: + self.bookmarks.append(bookmark) + else: + self.bookmarks.remove(bookmark_exists) + self.bookmarks.append(bookmark) + + def set_bookmarks_method(self, value): + if self.available_storage.get(value): + self.preferred = value + config.set_and_save('use_bookmarks_method', value) + + def save_remote(self, xmpp, callback): + """Save the remote bookmarks.""" + if not any(self.available_storage.values()): + return + method = 'xep_0049' if self.preferred == 'privatexml' else 'xep_0223' + + if method: + xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage(self.bookmarks), + method=method, + callback=callback) + def save_local(self): + """Save the local bookmarks.""" + local = ''.join(bookmark.local() for bookmark in self if bookmark.method == 'local') + config.set_and_save('rooms', local) + + def save(self, xmpp, core=None, callback=None): + """Save all the bookmarks.""" + self.save_local() + def _cb(iq): + if callback: + callback(iq) + if iq["type"] == "error" and core: + core.information('Could not save remote bookmarks.', 'Error') + elif core: + core.information('Bookmarks saved', 'Info') + if config.get('use_remote_bookmarks'): + self.save_remote(xmpp, _cb) + + def get_pep(self, xmpp, callback): + """Add the remotely stored bookmarks via pep to the list.""" + def _cb(iq): + if iq['type'] == 'result': + for conf in iq['pubsub']['items']['item']['bookmarks']['conferences']: + if isinstance(conf, URL): + continue + b = Bookmark.parse(conf) + self.append(b) + if callback: + callback(iq) + + xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223', callback=_cb) + + def get_privatexml(self, xmpp, callback): + """ + Fetch the remote bookmarks stored via privatexml. + """ + def _cb(iq): + if iq['type'] == 'result': + for conf in iq['private']['bookmarks']['conferences']: + b = Bookmark.parse(conf) + self.append(b) + if callback: + callback(iq) + + xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049', callback=_cb) + + def get_remote(self, xmpp, information, callback): + """Add the remotely stored bookmarks to the list.""" + force = config.get('force_remote_bookmarks') + if xmpp.anon or not (any(self.available_storage.values()) or force): + information('No remote bookmark storage available', 'Warning') + return + + if force and not any(self.available_storage.values()): + old_callback = callback + method = 'pep' if self.preferred == 'pep' else 'privatexml' + def new_callback(result): + if result['type'] != 'error': + self.available_storage[method] = True + old_callback(result) + else: + information('No remote bookmark storage available', 'Warning') + callback = new_callback + + if self.preferred == 'pep': + self.get_pep(xmpp, callback=callback) + else: + self.get_privatexml(xmpp, callback=callback) + + def get_local(self): + """Add the locally stored bookmarks to the list.""" + rooms = config.get('rooms') + if not rooms: + return + rooms = rooms.split(':') + for room in rooms: + jid = safeJID(room) + if jid.bare == '': + continue + if jid.resource != '': + nick = jid.resource + else: + nick = None + passwd = config.get_by_tabname('password', jid.bare, fallback=False) or None + b = Bookmark(jid.bare, autojoin=True, nick=nick, password=passwd, method='local') + self.append(b) + +def stanza_storage(bookmarks): + """Generate a <storage/> stanza with the conference elements.""" + storage = Bookmarks() + for b in (b for b in bookmarks if b.method == 'remote'): + storage.append(b.stanza()) + return storage diff --git a/src/config.py b/src/config.py index 1f0771ca..3ca53dd2 100644 --- a/src/config.py +++ b/src/config.py @@ -15,7 +15,7 @@ DEFSECTION = "Poezio" import logging.config import os import sys -from gettext import gettext as _ +import pkg_resources from configparser import RawConfigParser, NoOptionError, NoSectionError from os import environ, makedirs, path, remove @@ -28,12 +28,13 @@ DEFAULT_CONFIG = { 'add_space_after_completion': True, 'after_completion': ',', 'alternative_nickname': '', - 'auto_reconnect': False, + 'auto_reconnect': True, 'autorejoin_delay': '5', 'autorejoin': False, 'beep_on': 'highlight private invite', 'ca_cert_path': '', 'certificate': '', + 'certfile': '', 'ciphers': 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK:!SRP:!3DES:!aNULL', 'connection_check_interval': 60, 'connection_timeout_delay': 10, @@ -41,6 +42,8 @@ DEFAULT_CONFIG = { 'custom_host': '', 'custom_port': '', 'default_nick': '', + 'deterministic_nick_colors': True, + 'nick_color_aliases': True, 'display_activity_notifications': False, 'display_gaming_notifications': False, 'display_mood_notifications': False, @@ -58,6 +61,8 @@ DEFAULT_CONFIG = { 'extract_inline_images': True, 'filter_info_messages': '', 'force_encryption': True, + 'force_remote_bookmarks': False, + 'go_to_previous_tab_on_alt_number': False, 'group_corrections': True, 'hide_exit_join': -1, 'hide_status_change': 120, @@ -67,11 +72,11 @@ DEFAULT_CONFIG = { 'ignore_private': False, 'information_buffer_popup_on': 'error roster warning help info', 'jid': '', + 'keyfile': '', 'lang': 'en', 'lazy_resize': True, 'load_log': 10, 'log_dir': '', - 'logfile': 'logs', 'log_errors': True, 'max_lines_in_memory': 2048, 'max_messages_in_memory': 2048, @@ -131,6 +136,8 @@ DEFAULT_CONFIG = { 'var': { 'folded_roster_groups': '', 'info_win_height': 2 + }, + 'muc_colors': { } } @@ -152,9 +159,11 @@ class Config(RawConfigParser): except TypeError: # python < 3.2 sucks RawConfigParser.read(self, self.file_name) # Check config integrity and fix it if it’s wrong - for section in ('bindings', 'var'): - if not self.has_section(section): - self.add_section(section) + # only when the object is the main config + if self.__class__ is Config: + for section in ('bindings', 'var'): + if not self.has_section(section): + self.add_section(section) def get(self, option, default=None, section=DEFSECTION): """ @@ -400,9 +409,9 @@ class Config(RawConfigParser): elif current.lower() == "true": value = "false" else: - return (_('Could not toggle option: %s.' - ' Current value is %s.') % - (option, current or _("empty")), + return ('Could not toggle option: %s.' + ' Current value is %s.' % + (option, current or "empty"), 'Warning') if self.has_section(section): RawConfigParser.set(self, section, option, value) @@ -410,7 +419,7 @@ class Config(RawConfigParser): self.add_section(section) RawConfigParser.set(self, section, option, value) if not self.write_in_file(section, option, value): - return (_('Unable to write in the config file'), 'Error') + return ('Unable to write in the config file', 'Error') return ("%s=%s" % (option, value), 'Info') def remove_and_save(self, option, section=DEFSECTION): @@ -420,8 +429,8 @@ class Config(RawConfigParser): if self.has_section(section): RawConfigParser.remove_option(self, section, option) if not self.remove_in_file(section, option): - return (_('Unable to save the config file'), 'Error') - return (_('Option %s deleted') % option, 'Info') + return ('Unable to save the config file', 'Error') + return ('Option %s deleted' % option, 'Info') def silent_set(self, option, value, section=DEFSECTION): """ @@ -511,6 +520,34 @@ def check_create_cache_dir(): except OSError: pass +def check_config(): + """ + Check the config file and print results + """ + result = {'missing': [], 'changed': []} + for option in DEFAULT_CONFIG['Poezio']: + value = config.get(option) + if value != DEFAULT_CONFIG['Poezio'][option]: + result['changed'].append((option, value, DEFAULT_CONFIG['Poezio'][option])) + else: + value = config.get(option, default='') + upper = value.upper() + default = str(DEFAULT_CONFIG['Poezio'][option]).upper() + if upper != default: + result['missing'].append(option) + + result['changed'].sort(key=lambda x: x[0]) + result['missing'].sort() + if result['changed']: + print('\033[1mOptions changed from the default configuration:\033[0m\n') + for option, new_value, default in result['changed']: + print(' \033[1m%s\033[0m = \033[33m%s\033[0m (default: \033[32m%s\033[0m)' % (option, new_value, default)) + + if result['missing']: + print('\n\033[1mMissing options:\033[0m (the defaults are used)\n') + for option in result['missing']: + print(' \033[31m%s\033[0m' % option) + def run_cmdline_args(CONFIG_PATH): "Parse the command line arguments" global options @@ -518,9 +555,8 @@ def run_cmdline_args(CONFIG_PATH): # Copy a default file if none exists if not path.isfile(options.filename): - default = path.join(path.dirname(__file__), - '../data/default_config.cfg') - other = path.join(path.dirname(__file__), 'default_config.cfg') + default = path.join(path.dirname(__file__), '../data/default_config.cfg') + other = pkg_resources.resource_filename('poezio', 'default_config.cfg') if path.isfile(default): copy2(default, options.filename) elif path.isfile(other): @@ -552,7 +588,7 @@ def check_create_log_dir(): home = environ.get('HOME') data_dir = path.join(home, '.local', 'share') - LOG_DIR = path.join(data_dir, 'poezio') + LOG_DIR = path.join(data_dir, 'poezio', 'logs') LOG_DIR = path.expanduser(LOG_DIR) diff --git a/src/connection.py b/src/connection.py index 1bbe632d..cd2ccedd 100644 --- a/src/connection.py +++ b/src/connection.py @@ -30,6 +30,10 @@ class Connection(slixmpp.ClientXMPP): __init = False def __init__(self): resource = config.get('resource') + + keyfile = config.get('keyfile') + certfile = config.get('certfile') + if config.get('jid'): # Field used to know if we are anonymous or not. # many features will be handled differently @@ -38,7 +42,9 @@ class Connection(slixmpp.ClientXMPP): jid = '%s' % config.get('jid') if resource: jid = '%s/%s'% (jid, resource) - password = config.get('password') or getpass.getpass() + password = config.get('password') + if not password and not (keyfile and certfile): + password = getpass.getpass() else: # anonymous auth self.anon = True jid = config.get('server') @@ -57,6 +63,13 @@ class Connection(slixmpp.ClientXMPP): self['feature_mechanisms'].unencrypted_cram = False self['feature_mechanisms'].unencrypted_scram = False + self.keyfile = config.get('keyfile') + self.certfile = config.get('certfile') + if keyfile and not certfile: + log.error('keyfile is present in configuration file without certfile') + elif certfile and not keyfile: + log.error('certfile is present in configuration file without keyfile') + self.core = None self.auto_reconnect = config.get('auto_reconnect') self.reconnect_max_attempts = 0 @@ -127,6 +140,7 @@ class Connection(slixmpp.ClientXMPP): self.register_plugin('xep_0202') self.register_plugin('xep_0224') self.register_plugin('xep_0249') + self.register_plugin('xep_0257') self.register_plugin('xep_0280') self.register_plugin('xep_0297') self.register_plugin('xep_0308') diff --git a/src/core/commands.py b/src/core/commands.py index 4a8f7f19..3830d72a 100644 --- a/src/core/commands.py +++ b/src/core/commands.py @@ -6,37 +6,35 @@ import logging log = logging.getLogger(__name__) -import functools import os -import sys from datetime import datetime -from gettext import gettext as _ from xml.etree import cElementTree as ET from slixmpp.xmlstream.stanzabase import StanzaBase from slixmpp.xmlstream.handler import Callback from slixmpp.xmlstream.matcher import StanzaPath -import bookmark import common import fixes import pep import tabs +from bookmarks import Bookmark from common import safeJID -from config import config, options as config_opts +from config import config, DEFAULT_CONFIG, options as config_opts import multiuserchat as muc from plugin import PluginConfig from roster import roster from theming import dump_tuple, get_theme +from decorators import command_args_parser from . structs import Command, possible_show -def command_help(self, arg): +@command_args_parser.quoted(0, 1) +def command_help(self, args): """ - /help <command_name> + /help [command_name] """ - args = arg.split() if not args: color = dump_tuple(get_theme().COLOR_HELP_COMMANDS) acc = [] @@ -66,8 +64,8 @@ def command_help(self, arg): buff.extend(acc) msg = '\n'.join(buff) - msg += _("\nType /help <command_name> to know what each command does") - if args: + msg += "\nType /help <command_name> to know what each command does" + else: command = args[0].lstrip('/').strip() if command in self.current_tab().commands: @@ -75,16 +73,17 @@ def command_help(self, arg): elif command in self.commands: tup = self.commands[command] else: - self.information(_('Unknown command: %s') % command, 'Error') + self.information('Unknown command: %s' % command, 'Error') return if isinstance(tup, Command): - msg = _('Usage: /%s %s\n' % (command, tup.usage)) + msg = 'Usage: /%s %s\n' % (command, tup.usage) msg += tup.desc else: msg = tup[1] self.information(msg, 'Help') -def command_runkey(self, arg): +@command_args_parser.quoted(1) +def command_runkey(self, args): """ /runkey <key> """ @@ -93,7 +92,9 @@ def command_runkey(self, arg): if key == '^J': return '\n' return key - char = arg.strip() + if args is None: + return self.command_help('runkey') + char = args[0] func = self.key_func.get(char, None) if func: func() @@ -102,21 +103,20 @@ def command_runkey(self, arg): if res: self.refresh_window() -def command_status(self, arg): +@command_args_parser.quoted(1, 1, [None]) +def command_status(self, args): """ /status <status> [msg] """ - args = common.shell_split(arg) - if len(args) < 1: - return + if args is None: + return self.command_help('status') + if not args[0] in possible_show.keys(): - self.command_help('status') - return + return self.command_help('status') + show = possible_show[args[0]] - if len(args) == 2: - msg = args[1] - else: - msg = None + msg = args[1] + pres = self.xmpp.make_presence() if msg: pres['status'] = msg @@ -136,19 +136,15 @@ def command_status(self, arg): if is_muctab and current.joined and show not in ('away', 'xa'): current.send_chat_state('active') -def command_presence(self, arg): +@command_args_parser.quoted(1, 2, [None, None]) +def command_presence(self, args): """ /presence <JID> [type] [status] """ - args = common.shell_split(arg) - if len(args) == 1: - jid, type, status = args[0], None, None - elif len(args) == 2: - jid, type, status = args[0], args[1], None - elif len(args) == 3: - jid, type, status = args[0], args[1], args[2] - else: - return + if args is None: + return self.command_help('presence') + + jid, type, status = args[0], args[1], args[2] if jid == '.' and isinstance(self.current_tab(), tabs.ChatTab): jid = self.current_tab().name if type == 'available': @@ -158,7 +154,7 @@ def command_presence(self, arg): self.events.trigger('send_normal_presence', pres) pres.send() except: - self.information(_('Could not send directed presence'), 'Error') + self.information('Could not send directed presence', 'Error') log.debug('Could not send directed presence to %s', jid, exc_info=True) return tab = self.get_tab_by_name(jid) @@ -177,24 +173,26 @@ def command_presence(self, arg): if self.current_tab() in tab.privates: self.current_tab().send_chat_state(chatstate, True) -def command_theme(self, arg=''): +@command_args_parser.quoted(1) +def command_theme(self, args=None): """/theme <theme name>""" - args = arg.split() - if args: - self.command_set('theme %s' % (args[0],)) + if args is None: + return self.command_help('theme') + self.command_set('theme %s' % (args[0],)) -def command_win(self, arg): +@command_args_parser.quoted(1) +def command_win(self, args): """ /win <number> """ - arg = arg.strip() - if not arg: - self.command_help('win') - return + if args is None: + return self.command_help('win') + + nb = args[0] try: - nb = int(arg.split()[0]) + nb = int(nb) except ValueError: - nb = arg + pass if self.current_tab_nb == nb: return self.previous_tab_nb = self.current_tab_nb @@ -219,15 +217,15 @@ def command_win(self, arg): self.current_tab().on_gain_focus() self.refresh_window() -def command_move_tab(self, arg): +@command_args_parser.quoted(2) +def command_move_tab(self, args): """ /move_tab old_pos new_pos """ - args = common.shell_split(arg) - current_tab = self.current_tab() - if len(args) != 2: + if args is None: return self.command_help('move_tab') + current_tab = self.current_tab() if args[0] == '.': args[0] = current_tab.nb if args[1] == '.': @@ -259,16 +257,16 @@ def command_move_tab(self, arg): self.current_tab_nb = self.tabs.index(current_tab) self.refresh_window() -def command_list(self, arg): +@command_args_parser.quoted(0, 1) +def command_list(self, args): """ - /list <server> + /list [server] Opens a MucListTab containing the list of the room in the specified server """ - arg = arg.split() - if len(arg) > 1: + if args is None: return self.command_help('list') - elif arg: - server = safeJID(arg[0]).server + elif args: + server = safeJID(args[0]) else: if not isinstance(self.current_tab(), tabs.MucTab): return self.information('Please provide a server', 'Error') @@ -279,26 +277,27 @@ def command_list(self, arg): self.xmpp.plugin['xep_0030'].get_items(jid=server, callback=cb) -def command_version(self, arg): +@command_args_parser.quoted(1) +def command_version(self, args): """ /version <jid> """ def callback(res): "Callback for /version" if not res: - return self.information(_('Could not get the software' - ' version from %s') % jid, - _('Warning')) - version = _('%s is running %s version %s on %s') % ( + return self.information('Could not get the software' + ' version from %s' % jid, + 'Warning') + version = '%s is running %s version %s on %s' % ( jid, - res.get('name') or _('an unknown software'), - res.get('version') or _('unknown'), - res.get('os') or _('an unknown platform')) + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') self.information(version, 'Info') - args = common.shell_split(arg) - if len(args) < 1: + if args is None: return self.command_help('version') + jid = safeJID(args[0]) if jid.resource or jid not in roster: fixes.get_version(self.xmpp, jid, callback=callback) @@ -308,11 +307,11 @@ def command_version(self, arg): else: fixes.get_version(self.xmpp, jid, callback=callback) -def command_join(self, arg, histo_length=None): +@command_args_parser.quoted(0, 2) +def command_join(self, args, histo_length=None): """ /join [room][/nick] [password] """ - args = common.shell_split(arg) password = None if len(args) == 0: tab = self.current_tab() @@ -388,13 +387,15 @@ def command_join(self, arg, histo_length=None): seconds = int(seconds) else: seconds = 0 + if password: + tab.password = password muc.join_groupchat(self, room, nick, password, histo_length, current_status.message, current_status.show, seconds=seconds) if not tab: - self.open_new_room(room, nick) + self.open_new_room(room, nick, password=password) muc.join_groupchat(self, room, nick, password, histo_length, current_status.message, @@ -409,196 +410,162 @@ def command_join(self, arg, histo_length=None): tab.refresh() self.doupdate() -def command_bookmark_local(self, arg=''): +@command_args_parser.quoted(0, 2) +def command_bookmark_local(self, args): """ /bookmark_local [room][/nick] [password] """ - args = common.shell_split(arg) - nick = None - password = None if not args and not isinstance(self.current_tab(), tabs.MucTab): return - if not args: - tab = self.current_tab() - roomname = tab.name - if tab.joined and tab.own_nick != self.own_nick: - nick = tab.own_nick - elif args[0] == '*': - new_bookmarks = [] - for tab in self.get_tabs(tabs.MucTab): - b = bookmark.get_by_jid(tab.name) - if not b: - b = bookmark.Bookmark(tab.name, - autojoin=True, - method="local") - new_bookmarks.append(b) - else: - b.method = "local" - new_bookmarks.append(b) - bookmark.bookmarks.remove(b) - new_bookmarks.extend(bookmark.bookmarks) - bookmark.bookmarks = new_bookmarks - bookmark.save_local() - bookmark.save_remote(self.xmpp, None) - self.information('Bookmarks added and saved.', 'Info') - return - else: - info = safeJID(args[0]) - if info.resource != '': - nick = info.resource - roomname = info.bare - if not roomname: - if not isinstance(self.current_tab(), tabs.MucTab): - return - roomname = self.current_tab().name - if len(args) > 1: - password = args[1] - - bm = bookmark.get_by_jid(roomname) - if not bm: - bm = bookmark.Bookmark(jid=roomname) - bookmark.bookmarks.append(bm) - self.information('Bookmark added.', 'Info') - else: - self.information('Bookmark updated.', 'Info') - if nick: - bm.nick = nick - bm.autojoin = True - bm.password = password - bm.method = "local" - bookmark.save_local() - self.information(_('Your local bookmarks are now: %s') % - [b for b in bookmark.bookmarks if b.method == 'local'], 'Info') + password = args[1] if len(args) > 1 else None + jid = args[0] if args else None + + _add_bookmark(self, jid, True, password, 'local') -def command_bookmark(self, arg=''): +@command_args_parser.quoted(0, 3) +def command_bookmark(self, args): """ /bookmark [room][/nick] [autojoin] [password] """ + if not args and not isinstance(self.current_tab(), tabs.MucTab): + return + jid = args[0] if args else '' + password = args[2] if len(args) > 2 else None if not config.get('use_remote_bookmarks'): - self.command_bookmark_local(arg) - return - args = common.shell_split(arg) + return _add_bookmark(self, jid, True, password, 'local') + + if len(args) > 1: + autojoin = False if args[1].lower() != 'true' else True + else: + autojoin = True + + _add_bookmark(self, jid, autojoin, password, 'remote') + +def _add_bookmark(self, jid, autojoin, password, method): nick = None - if not args and not isinstance(self.current_tab(), tabs.MucTab): - return - if not args: + if not jid: tab = self.current_tab() roomname = tab.name - if tab.joined: + if tab.joined and tab.own_nick != self.own_nick: nick = tab.own_nick - autojoin = True - password = None - elif args[0] == '*': - if len(args) > 1: - autojoin = False if args[1].lower() != 'true' else True - else: - autojoin = True - new_bookmarks = [] - for tab in self.get_tabs(tabs.MucTab): - b = bookmark.get_by_jid(tab.name) - if not b: - b = bookmark.Bookmark(tab.name, autojoin=autojoin, - method=bookmark.preferred) - new_bookmarks.append(b) - else: - b.method = bookmark.preferred - bookmark.bookmarks.remove(b) - new_bookmarks.append(b) - new_bookmarks.extend(bookmark.bookmarks) - bookmark.bookmarks = new_bookmarks - def _cb(self, iq): - if iq["type"] != "error": - bookmark.save_local() - self.information("Bookmarks added.", "Info") - else: - self.information("Could not add the bookmarks.", "Info") - bookmark.save_remote(self.xmpp, functools.partial(_cb, self)) - return + if password is None and tab.password is not None: + password = tab.password + elif jid == '*': + return _add_wildcard_bookmarks(self, method) else: - info = safeJID(args[0]) - if info.resource != '': - nick = info.resource - roomname = info.bare + info = safeJID(jid) + roomname, nick = info.bare, info.resource if roomname == '': if not isinstance(self.current_tab(), tabs.MucTab): return roomname = self.current_tab().name - if len(args) > 1: - autojoin = False if args[1].lower() != 'true' else True - else: - autojoin = True - if len(args) > 2: - password = args[2] - else: - password = None - bm = bookmark.get_by_jid(roomname) - if not bm: - bm = bookmark.Bookmark(roomname) - bookmark.bookmarks.append(bm) - bm.method = config.get('use_bookmarks_method') + bookmark = self.bookmarks[roomname] + if bookmark is None: + bookmark = Bookmark(roomname) + self.bookmarks.append(bookmark) + bookmark.method = method + bookmark.autojoin = autojoin if nick: - bm.nick = nick + bookmark.nick = nick if password: - bm.password = password - bm.autojoin = autojoin - def _cb(self, iq): + bookmark.password = password + def callback(iq): if iq["type"] != "error": self.information('Bookmark added.', 'Info') else: self.information("Could not add the bookmarks.", "Info") - bookmark.save_remote(self.xmpp, functools.partial(_cb, self)) - remote = [] - for each in bookmark.bookmarks: - if each.method in ('pep', 'privatexml'): - remote.append(each) + self.bookmarks.save_local() + self.bookmarks.save_remote(self.xmpp, callback) + +def _add_wildcard_bookmarks(self, method): + new_bookmarks = [] + for tab in self.get_tabs(tabs.MucTab): + bookmark = self.bookmarks[tab.name] + if not bookmark: + bookmark = Bookmark(tab.name, autojoin=True, + method=method) + new_bookmarks.append(bookmark) + else: + bookmark.method = method + new_bookmarks.append(bookmark) + self.bookmarks.remove(bookmark) + new_bookmarks.extend(self.bookmarks.bookmarks) + self.bookmarks.set(new_bookmarks) + def _cb(iq): + if iq["type"] != "error": + self.information("Bookmarks saved.", "Info") + else: + self.information("Could not save the remote bookmarks.", "Info") + self.bookmarks.save_local() + self.bookmarks.save_remote(self.xmpp, _cb) -def command_bookmarks(self, arg=''): +@command_args_parser.ignored +def command_bookmarks(self): """/bookmarks""" - local = [] - remote = [] - for each in bookmark.bookmarks: - if each.method in ('pep', 'privatexml'): - remote.append(each) - elif each.method == 'local': - local.append(each) - - self.information(_('Your remote bookmarks are: %s') % remote, - _('Info')) - self.information(_('Your local bookmarks are: %s') % local, - _('Info')) - -def command_remove_bookmark(self, arg=''): + tab = self.get_tab_by_name('Bookmarks', tabs.BookmarksTab) + old_tab = self.current_tab() + if tab: + self.current_tab_nb = tab.nb + else: + tab = tabs.BookmarksTab(self.bookmarks) + self.tabs.append(tab) + self.current_tab_nb = tab.nb + old_tab.on_lose_focus() + tab.on_gain_focus() + self.refresh_window() + +@command_args_parser.quoted(0, 1) +def command_remove_bookmark(self, args): """/remove_bookmark [jid]""" - args = common.shell_split(arg) + + def cb(success): + if success: + self.information('Bookmark deleted', 'Info') + else: + self.information('Error while deleting the bookmark', 'Error') + if not args: tab = self.current_tab() - if isinstance(tab, tabs.MucTab) and bookmark.get_by_jid(tab.name): - bookmark.remove(tab.name) - bookmark.save(self.xmpp) - if bookmark.save(self.xmpp): - self.information('Bookmark deleted', 'Info') + if isinstance(tab, tabs.MucTab) and self.bookmarks[tab.name]: + self.bookmarks.remove(tab.name) + self.bookmarks.save(self.xmpp, callback=cb) else: self.information('No bookmark to remove', 'Info') else: - if bookmark.get_by_jid(args[0]): - bookmark.remove(args[0]) - if bookmark.save(self.xmpp): - self.information('Bookmark deleted', 'Info') - + if self.bookmarks[args[0]]: + self.bookmarks.remove(args[0]) + self.bookmarks.save(self.xmpp, callback=cb) else: self.information('No bookmark to remove', 'Info') -def command_set(self, arg): +@command_args_parser.quoted(0, 3) +def command_set(self, args): """ /set [module|][section] <option> [value] """ - args = common.shell_split(arg) - if len(args) == 1: + if args is None or len(args) == 0: + config_dict = config.to_dict() + lines = [] + theme = get_theme() + for section_name, section in config_dict.items(): + lines.append('\x19%(section_col)s}[%(section)s]\x19o' % + { + 'section': section_name, + 'section_col': dump_tuple(theme.COLOR_INFORMATION_TEXT), + }) + for option_name, option_value in section.items(): + lines.append('%s\x19%s}=\x19o%s' % (option_name, + dump_tuple(theme.COLOR_REVISIONS_MESSAGE), + option_value)) + info = ('Current options:\n%s' % '\n'.join(lines), 'Info') + elif len(args) == 1: option = args[0] value = config.get(option) + if value is None and '=' in option: + args = option.split('=', 1) info = ('%s=%s' % (option, value), 'Info') - elif len(args) == 2: + if len(args) == 2: if '|' in args[0]: plugin_name, section = args[0].split('|')[:2] if not section: @@ -639,44 +606,72 @@ def command_set(self, arg): plugin_config = self.plugin_manager.plugins[plugin_name].config info = plugin_config.set_and_save(option, value, section) else: - section = args[0] + if args[0] == '.': + name = safeJID(self.current_tab().name).bare + if not name: + self.information('Invalid tab to use the "." argument.', + 'Error') + return + section = name + else: + section = args[0] option = args[1] value = args[2] info = config.set_and_save(option, value, section) self.trigger_configuration_change(option, value) - else: - self.command_help('set') - return - self.call_for_resize() + elif len(args) > 3: + return self.command_help('set') self.information(*info) -def command_toggle(self, arg): +@command_args_parser.quoted(1, 2) +def command_set_default(self, args): + """ + /set_default [section] <option> + """ + if len(args) == 1: + option = args[0] + section = 'Poezio' + elif len(args) == 2: + section = args[0] + option = args[1] + else: + return self.command_help('set_default') + + default_config = DEFAULT_CONFIG.get(section, tuple()) + if option not in default_config: + info = ("Option %s has no default value" % (option), "Error") + return self.information(*info) + self.command_set('%s %s %s' % (section, option, default_config[option])) + +@command_args_parser.quoted(1) +def command_toggle(self, args): """ /toggle <option> shortcut for /set <option> toggle """ - arg = arg.split() - if arg and arg[0]: - self.command_set('%s toggle' % arg[0]) + if args is None: + return self.command_help('toggle') -def command_server_cycle(self, arg=''): + if args[0]: + self.command_set('%s toggle' % args[0]) + +@command_args_parser.quoted(1, 1) +def command_server_cycle(self, args): """ Do a /cycle on each room of the given server. If none, do it on the current tab """ - args = common.shell_split(arg) tab = self.current_tab() message = "" - if len(args): + if args: domain = args[0] - if len(args) > 1: + if len(args) == 2: message = args[1] else: if isinstance(tab, tabs.MucTab): domain = safeJID(tab.name).domain else: - self.information(_("No server specified"), "Error") - return + return self.information("No server specified", "Error") for tab in self.get_tabs(tabs.MucTab): if tab.name.endswith(domain): if tab.joined: @@ -690,7 +685,8 @@ def command_server_cycle(self, arg=''): else: self.command_join('"%s/%s"' %(tab.name, tab.own_nick)) -def command_last_activity(self, arg): +@command_args_parser.quoted(1) +def command_last_activity(self, args): """ /last_activity <jid> """ @@ -698,11 +694,11 @@ def command_last_activity(self, arg): "Callback for the last activity" if iq['type'] != 'result': if iq['error']['type'] == 'auth': - self.information(_('You are not allowed to see the ' - 'activity of this contact.'), - _('Error')) + self.information('You are not allowed to see the ' + 'activity of this contact.', + 'Error') else: - self.information(_('Error retrieving the activity'), 'Error') + self.information('Error retrieving the activity', 'Error') return seconds = iq['last_activity']['seconds'] status = iq['last_activity']['status'] @@ -717,46 +713,47 @@ def command_last_activity(self, arg): common.parse_secs_to_str(seconds), (' and his/her last status was %s' % status) if status else '') self.information(msg, 'Info') - jid = safeJID(arg) - if jid == '': + + if args is None: return self.command_help('last_activity') + jid = safeJID(args[0]) self.xmpp.plugin['xep_0012'].get_last_activity(jid, callback=callback) -def command_mood(self, arg): +@command_args_parser.quoted(0, 2) +def command_mood(self, args): """ /mood [<mood> [text]] """ - args = common.shell_split(arg) if not args: - self.xmpp.plugin['xep_0107'].stop() - return + return self.xmpp.plugin['xep_0107'].stop() + mood = args[0] if mood not in pep.MOODS: - return self.information(_('%s is not a correct value for a mood.') - % mood, - _('Error')) - if len(args) > 1: + return self.information('%s is not a correct value for a mood.' + % mood, + 'Error') + if len(args) == 2: text = args[1] else: text = None self.xmpp.plugin['xep_0107'].publish_mood(mood, text, callback=dumb_callback) -def command_activity(self, arg): +@command_args_parser.quoted(0, 3) +def command_activity(self, args): """ /activity [<general> [specific] [text]] """ - args = common.shell_split(arg) length = len(args) if not length: - self.xmpp.plugin['xep_0108'].stop() - return + return self.xmpp.plugin['xep_0108'].stop() + general = args[0] if general not in pep.ACTIVITIES: - return self.information(_('%s is not a correct value for an activity') + return self.information('%s is not a correct value for an activity' % general, - _('Error')) + 'Error') specific = None text = None if length == 2: @@ -768,20 +765,20 @@ def command_activity(self, arg): specific = args[1] text = args[2] if specific and specific not in pep.ACTIVITIES[general]: - return self.information(_('%s is not a correct value ' - 'for an activity') % specific, - _('Error')) + return self.information('%s is not a correct value ' + 'for an activity' % specific, + 'Error') self.xmpp.plugin['xep_0108'].publish_activity(general, specific, text, callback=dumb_callback) -def command_gaming(self, arg): +@command_args_parser.quoted(0, 2) +def command_gaming(self, args): """ /gaming [<game name> [server address]] """ - args = common.shell_split(arg) if not args: - self.xmpp.plugin['xep_0196'].stop() - return + return self.xmpp.plugin['xep_0196'].stop() + name = args[0] if len(args) > 1: address = args[1] @@ -791,25 +788,27 @@ def command_gaming(self, arg): server_address=address, callback=dumb_callback) -def command_invite(self, arg): +@command_args_parser.quoted(2, 1, [None]) +def command_invite(self, args): """/invite <to> <room> [reason]""" - args = common.shell_split(arg) - if len(args) < 2: - return - reason = args[2] if len(args) > 2 else None + + if args is None: + return self.command_help('invite') + + reason = args[2] to = safeJID(args[0]) room = safeJID(args[1]).bare self.invite(to.full, room, reason=reason) -def command_decline(self, arg): +@command_args_parser.quoted(1, 1, ['']) +def command_decline(self, args): """/decline <room@server.tld> [reason]""" - args = common.shell_split(arg) - if not len(args): - return + if args is None: + return self.command_help('decline') jid = safeJID(args[0]) if jid.bare not in self.pending_invites: return - reason = args[1] if len(args) > 1 else '' + reason = args[1] del self.pending_invites[jid.bare] self.xmpp.plugin['xep_0045'].decline_invite(jid.bare, self.pending_invites[jid.bare], @@ -817,7 +816,8 @@ def command_decline(self, arg): ### Commands without a completion in this class ### -def command_invitations(self, arg=''): +@command_args_parser.ignored +def command_invitations(self): """/invitations""" build = "" for invite in self.pending_invites: @@ -829,17 +829,16 @@ def command_invitations(self, arg=''): build = "You do not have any pending invitations." self.information(build, 'Info') -def command_quit(self, arg=''): +@command_args_parser.quoted(0, 1, [None]) +def command_quit(self, args): """ - /quit + /quit [message] """ if not self.xmpp.is_connected(): self.exit() return - if len(arg.strip()) != 0: - msg = arg - else: - msg = None + + msg = args[0] if config.get('enable_user_mood'): self.xmpp.plugin['xep_0107'].stop() if config.get('enable_user_activity'): @@ -851,44 +850,47 @@ def command_quit(self, arg=''): self.disconnect(msg) self.xmpp.add_event_handler("disconnected", self.exit, disposable=True) -def command_destroy_room(self, arg=''): +@command_args_parser.quoted(0, 1, ['']) +def command_destroy_room(self, args): """ /destroy_room [JID] """ - room = safeJID(arg).bare + room = safeJID(args[0]).bare if room: muc.destroy_room(self.xmpp, room) - elif isinstance(self.current_tab(), tabs.MucTab) and not arg: + elif isinstance(self.current_tab(), tabs.MucTab) and not args[0]: muc.destroy_room(self.xmpp, self.current_tab().general_jid) else: - self.information(_('Invalid JID: "%s"') % arg, _('Error')) + self.information('Invalid JID: "%s"' % args[0], 'Error') -def command_bind(self, arg): +@command_args_parser.quoted(1, 1, ['']) +def command_bind(self, args): """ Bind a key. """ - args = common.shell_split(arg) - if len(args) < 1: + if args is None: return self.command_help('bind') - elif len(args) < 2: - args.append("") + if not config.silent_set(args[0], args[1], section='bindings'): - self.information(_('Unable to write in the config file'), 'Error') + self.information('Unable to write in the config file', 'Error') + if args[1]: self.information('%s is now bound to %s' % (args[0], args[1]), 'Info') else: self.information('%s is now unbound' % args[0], 'Info') -def command_rawxml(self, arg): +@command_args_parser.raw +def command_rawxml(self, args): """ /rawxml <xml stanza> """ - if not arg: - return + if not args: + return + stanza = args try: - stanza = StanzaBase(self.xmpp, xml=ET.fromstring(arg)) + stanza = StanzaBase(self.xmpp, xml=ET.fromstring(stanza)) if stanza.xml.tag == 'iq' and \ stanza.xml.attrib.get('type') in ('get', 'set') and \ stanza.xml.attrib.get('id'): @@ -910,78 +912,85 @@ def command_rawxml(self, arg): stanza.send() except: - self.information(_('Could not send custom stanza'), 'Error') + self.information('Could not send custom stanza', 'Error') log.debug('/rawxml: Could not send custom stanza (%s)', - repr(arg), + repr(stanza), exc_info=True) -def command_load(self, arg): +@command_args_parser.quoted(1, 256) +def command_load(self, args): """ /load <plugin> [<otherplugin> …] + # TODO: being able to load more than 256 plugins at once, hihi. """ - args = arg.split() for plugin in args: self.plugin_manager.load(plugin) -def command_unload(self, arg): +@command_args_parser.quoted(1, 256) +def command_unload(self, args): """ /unload <plugin> [<otherplugin> …] """ - args = arg.split() for plugin in args: self.plugin_manager.unload(plugin) -def command_plugins(self, arg=''): +@command_args_parser.ignored +def command_plugins(self): """ /plugins """ - self.information(_("Plugins currently in use: %s") % + self.information("Plugins currently in use: %s" % repr(list(self.plugin_manager.plugins.keys())), - _('Info')) + 'Info') -def command_message(self, arg): +@command_args_parser.quoted(1, 1) +def command_message(self, args): """ /message <jid> [message] """ - args = common.shell_split(arg) - if len(args) < 1: - self.command_help('message') - return + if args is None: + return self.command_help('message') jid = safeJID(args[0]) if not jid.user and not jid.domain and not jid.resource: return self.information('Invalid JID.', 'Error') tab = self.get_conversation_by_jid(jid.full, False, fallback_barejid=False) - if not tab: + muc = self.get_tab_by_name(jid.bare, typ=tabs.MucTab) + if not tab and not muc: tab = self.open_conversation_window(jid.full, focus=True) + elif muc: + tab = self.get_tab_by_name(jid.full, typ=tabs.PrivateTab) + if tab: + self.focus_tab_named(tab.name) + else: + tab = self.open_private_window(jid.bare, jid.resource) else: self.focus_tab_named(tab.name) - if len(args) > 1: + if len(args) == 2: tab.command_say(args[1]) -def command_xml_tab(self, arg=''): +@command_args_parser.ignored +def command_xml_tab(self): """/xml_tab""" - self.xml_tab = True xml_tab = self.focus_tab_named('XMLTab', tabs.XMLTab) if not xml_tab: tab = tabs.XMLTab() self.add_tab(tab, True) + self.xml_tab = tab -def command_adhoc(self, arg): - arg = arg.split() - if len(arg) > 1: +@command_args_parser.quoted(1) +def command_adhoc(self, args): + if not args: return self.command_help('ad-hoc') - elif arg: - jid = safeJID(arg[0]) - else: - return self.information('Please provide a jid', 'Error') + jid = safeJID(args[0]) list_tab = tabs.AdhocCommandsListTab(jid) self.add_tab(list_tab, True) cb = list_tab.on_list_received self.xmpp.plugin['xep_0050'].get_commands(jid=jid, local=False, callback=cb) -def command_self(self, arg=None): +@command_args_parser.ignored +def command_self(self): """ /self """ @@ -998,5 +1007,14 @@ def command_self(self, arg=None): config_opts.version)) self.information(info, 'Info') + +@command_args_parser.ignored +def command_reload(self): + """ + /reload + """ + self.reload_config() + def dumb_callback(*args, **kwargs): "mock callback" + diff --git a/src/core/completions.py b/src/core/completions.py index 7d95321b..f17e916c 100644 --- a/src/core/completions.py +++ b/src/core/completions.py @@ -8,7 +8,6 @@ log = logging.getLogger(__name__) import os from functools import reduce -import bookmark import common import pep import tabs @@ -57,7 +56,7 @@ def completion_theme(self, the_input): except OSError as e: log.error('Completion for /theme failed', exc_info=True) return - theme_files = [name[:-3] for name in names if name.endswith('.py')] + theme_files = [name[:-3] for name in names if name.endswith('.py') and name != '__init__.py'] if not 'default' in theme_files: theme_files.append('default') return the_input.new_completion(theme_files, 1, '', quotify=False) @@ -96,7 +95,7 @@ def completion_join(self, the_input): relevant_rooms = [] relevant_rooms.extend(sorted(self.pending_invites.keys())) - bookmarks = {str(elem.jid): False for elem in bookmark.bookmarks} + bookmarks = {str(elem.jid): False for elem in self.bookmarks} for tab in self.get_tabs(tabs.MucTab): name = tab.name if name in bookmarks and not tab.joined: @@ -119,7 +118,6 @@ def completion_join(self, the_input): return the_input.new_completion(['/%s' % self.own_nick], 1, quotify=True) else: return the_input.new_completion(relevant_rooms, 1, quotify=True) - return True def completion_version(self, the_input): @@ -192,7 +190,7 @@ def completion_bookmark(self, the_input): def completion_remove_bookmark(self, the_input): """Completion for /remove_bookmark""" - return the_input.new_completion([bm.jid for bm in bookmark.bookmarks], 1, quotify=False) + return the_input.new_completion([bm.jid for bm in self.bookmarks], 1, quotify=False) def completion_decline(self, the_input): @@ -214,9 +212,6 @@ def completion_bind(self, the_input): return the_input.new_completion(args, n, '', quotify=False) - return the_input - - def completion_message(self, the_input): """Completion for /message""" n = the_input.get_argument_position(quoted=True) @@ -304,14 +299,21 @@ def completion_set(self, the_input): plugin = self.plugin_manager.plugins[plugin_name] end_list = ['%s|%s' % (plugin_name, section) for section in plugin.config.sections()] else: - end_list = config.options('Poezio') + end_list = set(config.options('Poezio')) + end_list.update(config.default.get('Poezio', {})) + end_list = list(end_list) + end_list.sort() elif n == 2: if '|' in args[1]: plugin_name, section = args[1].split('|')[:2] if not plugin_name in self.plugin_manager.plugins: return the_input.new_completion([''], n, quotify=True) plugin = self.plugin_manager.plugins[plugin_name] - end_list = plugin.config.options(section or plugin_name) + end_list = set(plugin.config.options(section or plugin_name)) + if plugin.config.default: + end_list.update(plugin.config.default.get(section or plugin_name, {})) + end_list = list(end_list) + end_list.sort() elif not config.has_option('Poezio', args[1]): if config.has_section(args[1]): end_list = config.options(args[1]) @@ -336,6 +338,19 @@ def completion_set(self, the_input): return return the_input.new_completion(end_list, n, quotify=True) + +def completion_set_default(self, the_input): + """ Completion for /set_default + """ + args = common.shell_split(the_input.text) + n = the_input.get_argument_position(quoted=True) + if n >= len(args): + args.append('') + if n == 1 or (n == 2 and config.has_section(args[1])): + return self.completion_set(the_input) + return [] + + def completion_toggle(self, the_input): "Completion for /toggle" return the_input.new_completion(config.options('Poezio'), 1, quotify=False) diff --git a/src/core/core.py b/src/core/core.py index 4daeed6c..92c9f987 100644 --- a/src/core/core.py +++ b/src/core/core.py @@ -10,7 +10,6 @@ import logging log = logging.getLogger(__name__) import asyncio -import collections import shutil import curses import os @@ -18,22 +17,19 @@ import pipes import sys import time from threading import Event -from datetime import datetime -from gettext import gettext as _ from slixmpp.xmlstream.handler import Callback -import bookmark import connection import decorators import events -import fixes import singleton import tabs import theming import timed_events import windows +from bookmarks import BookmarkList from common import safeJID from config import config, firstrun from contact import Contact, Resource @@ -75,6 +71,7 @@ class Core(object): self.keyboard = keyboard.Keyboard() roster.set_node(self.xmpp.client_roster) decorators.refresh_wrapper.core = self + self.bookmarks = BookmarkList() self.paused = False self.event = Event() self.debug = False @@ -90,7 +87,7 @@ class Core(object): self.tab_win = windows.GlobalInfoBar() # Whether the XML tab is opened - self.xml_tab = False + self.xml_tab = None self.xml_buffer = TextBuffer() self.tabs = [] @@ -226,6 +223,7 @@ class Core(object): self.xmpp.add_event_handler("groupchat_subject", self.on_groupchat_subject) self.xmpp.add_event_handler("message", self.on_message) + self.xmpp.add_event_handler("message_error", self.on_error_message) self.xmpp.add_event_handler("receipt_received", self.on_receipt) self.xmpp.add_event_handler("got_online", self.on_got_online) self.xmpp.add_event_handler("got_offline", self.on_got_offline) @@ -253,6 +251,7 @@ class Core(object): self.on_chatstate_inactive) self.xmpp.add_event_handler("attention", self.on_attention) self.xmpp.add_event_handler("ssl_cert", self.validate_ssl) + self.xmpp.add_event_handler("ssl_invalid_chain", self.ssl_invalid_chain) self.all_stanzas = Callback('custom matcher', connection.MatchAll(None), self.incoming_stanza) @@ -310,8 +309,14 @@ class Core(object): theming.update_themes_dir) self.add_configuration_handler("theme", self.on_theme_config_change) + self.add_configuration_handler("use_bookmarks_method", + self.on_bookmarks_method_config_change) self.add_configuration_handler("password", self.on_password_change) + self.add_configuration_handler("enable_vertical_tab_list", + self.on_vertical_tab_list_config_change) + self.add_configuration_handler("deterministic_nick_colors", + self.on_nick_determinism_changed) self.add_configuration_handler("", self.on_any_config_change) @@ -346,6 +351,15 @@ class Core(object): for callback in self.configuration_change_handlers[option]: callback(option, value) + def on_bookmarks_method_config_change(self, option, value): + """ + Called when the use_bookmarks_method option changes + """ + if value not in ('pep', 'privatexml'): + return + self.bookmarks.preferred = value + self.bookmarks.save(self.xmpp, core=self) + def on_gaps_config_change(self, option, value): """ Called when the option create_gaps is changed. @@ -374,6 +388,12 @@ class Core(object): path = os.path.expanduser(value) self.plugin_manager.on_plugins_dir_change(path) + def on_vertical_tab_list_config_change(self, option, value): + """ + Called when the enable_vertical_tab_list option is changed + """ + self.call_for_resize() + def on_plugins_conf_dir_config_change(self, option, value): """ Called when the plugins_conf_dir option is changed @@ -396,19 +416,23 @@ class Core(object): """ self.xmpp.password = value - def sigusr_handler(self, num, stack): - """ - Handle SIGUSR1 (10) - When caught, reload all the possible files. + + def on_nick_determinism_changed(self, option, value): + """If we change the value to true, we call /recolor on all the MucTabs, to + make the current nick colors reflect their deterministic value. """ - log.debug("SIGUSR1 caught, reloading the files…") + if value.lower() == "true": + for tab in self.get_tabs(tabs.MucTab): + tab.command_recolor('') + + def reload_config(self): # reload all log files log.debug("Reloading the log files…") logger.reload_all() log.debug("Log files reloaded.") # reload the theme log.debug("Reloading the theme…") - self.command_theme("") + theming.reload_theme() log.debug("Theme reloaded.") # reload the config from the disk log.debug("Reloading the config…") @@ -428,6 +452,14 @@ class Core(object): # in case some roster options have changed roster.modified() + def sigusr_handler(self, num, stack): + """ + Handle SIGUSR1 (10) + When caught, reload all the possible files. + """ + log.debug("SIGUSR1 caught, reloading the files…") + self.reload_config() + def exit_from_signal(self, *args, **kwargs): """ Quit when receiving SIGHUP or SIGTERM or SIGPIPE @@ -476,15 +508,15 @@ class Core(object): default_tab = tabs.RosterInfoTab() default_tab.on_gain_focus() self.tabs.append(default_tab) - self.information(_('Welcome to poezio!'), _('Info')) + self.information('Welcome to poezio!', 'Info') if firstrun: - self.information(_( + self.information( 'It seems that it is the first time you start poezio.\n' 'The online help is here http://doc.poez.io/\n' 'No room is joined by default, but you can join poezio’s' 'chatroom (with /join poezio@muc.poez.io), where you can' - ' ask for help or tell us how great it is.'), - _('Help')) + ' ask for help or tell us how great it is.', + 'Help') self.refresh_window() self.xmpp.plugin['xep_0012'].begin_idle(jid=self.xmpp.boundjid) @@ -592,7 +624,7 @@ class Core(object): except ValueError: pass else: - if self.current_tab().nb == nb: + if self.current_tab().nb == nb and config.get('go_to_previous_tab_on_alt_number'): self.go_to_previous_tab() else: self.command_win('%d' % nb) @@ -617,9 +649,9 @@ class Core(object): self.information_win_size, 'var') if not ok: - self.information(_('Unable to save runtime preferences' - ' in the config file'), - _('Error')) + self.information('Unable to save runtime preferences' + ' in the config file', + 'Error') def on_roster_enter_key(self, roster_row): """ @@ -675,8 +707,8 @@ class Core(object): func(arg) return else: - self.information(_("Unknown command (%s)") % (command), - _('Error')) + self.information("Unknown command (%s)" % (command), + 'Error') def exec_command(self, command): """ @@ -809,15 +841,15 @@ class Core(object): msg = msg.replace('\n', '|') if msg else '' ok = ok and config.silent_set('status_message', msg) if not ok: - self.information(_('Unable to save the status in ' - 'the config file'), 'Error') + self.information('Unable to save the status in ' + 'the config file', 'Error') def get_bookmark_nickname(self, room_name): """ Returns the nickname associated with a bookmark or the default nickname """ - bm = bookmark.get_by_jid(room_name) + bm = self.bookmarks[room_name] if bm: return bm.nick return self.own_nick @@ -884,18 +916,18 @@ class Core(object): if code in DEPRECATED_ERRORS: body = DEPRECATED_ERRORS[code] else: - body = condition or _('Unknown error') + body = condition or 'Unknown error' else: if code in ERROR_AND_STATUS_CODES: body = ERROR_AND_STATUS_CODES[code] else: - body = condition or _('Unknown error') + body = condition or 'Unknown error' if code: - message = _('%(from)s: %(code)s - %(msg)s: %(body)s') % { - 'from': sender, 'msg': msg, 'body': body, 'code': code} + message = '%(from)s: %(code)s - %(msg)s: %(body)s' % { + 'from': sender, 'msg': msg, 'body': body, 'code': code} else: - message = _('%(from)s: %(msg)s: %(body)s') % { - 'from': sender, 'msg': msg, 'body': body} + message = '%(from)s: %(msg)s: %(body)s' % { + 'from': sender, 'msg': msg, 'body': body} return message @@ -1163,7 +1195,7 @@ class Core(object): self._current_tab_nb = len(self.tabs) - 1 else: self._current_tab_nb = value - if old != self._current_tab_nb: + if old != self._current_tab_nb and self.tabs[self._current_tab_nb]: self.events.trigger('tab_change', old, self._current_tab_nb) ### Opening actions ### @@ -1209,11 +1241,11 @@ class Core(object): tab.privates.append(new_tab) return new_tab - def open_new_room(self, room, nick, focus=True): + def open_new_room(self, room, nick, *, password=None, focus=True): """ Open a new tab.MucTab containing a muc Room, using the specified nick """ - new_tab = tabs.MucTab(room, nick) + new_tab = tabs.MucTab(room, nick, password=password) self.add_tab(new_tab, focus) self.refresh_window() @@ -1262,7 +1294,7 @@ class Core(object): Disable private tabs when leaving a room """ if reason is None: - reason = _('\x195}You left the chatroom\x193}') + reason = '\x195}You left the chatroom\x193}' for tab in self.get_tabs(tabs.PrivateTab): if tab.name.startswith(room_name): tab.deactivate(reason=reason) @@ -1272,7 +1304,7 @@ class Core(object): Enable private tabs when joining a room """ if reason is None: - reason = _('\x195}You joined the chatroom\x193}') + reason = '\x195}You joined the chatroom\x193}' for tab in self.get_tabs(tabs.PrivateTab): if tab.name.startswith(room_name): tab.activate(reason=reason) @@ -1286,6 +1318,7 @@ class Core(object): """ Close the given tab. If None, close the current one """ + was_current = tab is None tab = tab or self.current_tab() if isinstance(tab, tabs.RosterInfoTab): return # The tab 0 should NEVER be closed @@ -1293,9 +1326,10 @@ class Core(object): del tab.commands # and make the object collectable tab.on_close() nb = tab.nb - if self.previous_tab_nb != nb: - self.current_tab_nb = self.previous_tab_nb - self.previous_tab_nb = 0 + if was_current: + if self.previous_tab_nb != nb: + self.current_tab_nb = self.previous_tab_nb + self.previous_tab_nb = 0 if config.get('create_gaps'): if nb >= len(self.tabs) - 1: self.tabs.remove(tab) @@ -1315,7 +1349,8 @@ class Core(object): self.current_tab_nb = len(self.tabs) - 1 while not self.tabs[self.current_tab_nb]: self.current_tab_nb -= 1 - self.current_tab().on_gain_focus() + if was_current: + self.current_tab().on_gain_focus() self.refresh_window() import gc gc.collect() @@ -1403,9 +1438,11 @@ class Core(object): """ Refresh everything """ + nocursor = curses.curs_set(0) self.current_tab().state = 'current' self.current_tab().refresh() self.doupdate() + curses.curs_set(nocursor) def refresh_tab_win(self): """ @@ -1556,7 +1593,7 @@ class Core(object): """ enabled = config.get('enable_vertical_tab_list') if not config.silent_set('enable_vertical_tab_list', str(not enabled)): - self.information(_('Unable to write in the config file'), 'Error') + self.information('Unable to write in the config file', 'Error') self.call_for_resize() def resize_global_information_win(self): @@ -1681,214 +1718,225 @@ class Core(object): Register the commands when poezio starts """ self.register_command('help', self.command_help, - usage=_('[command]'), + usage='[command]', shortdesc='\\_o< KOIN KOIN KOIN', completion=self.completion_help) self.register_command('join', self.command_join, - usage=_("[room_name][@server][/nick] [password]"), - desc=_("Join the specified room. You can specify a nickname " - "after a slash (/). If no nickname is specified, you will" - " use the default_nick in the configuration file. You can" - " omit the room name: you will then join the room you\'re" - " looking at (useful if you were kicked). You can also " - "provide a room_name without specifying a server, the " - "server of the room you're currently in will be used. You" - " can also provide a password to join the room.\nExamples" - ":\n/join room@server.tld\n/join room@server.tld/John\n" - "/join room2\n/join /me_again\n/join\n/join room@server" - ".tld/my_nick password\n/join / password"), - shortdesc=_('Join a room'), + usage="[room_name][@server][/nick] [password]", + desc="Join the specified room. You can specify a nickname " + "after a slash (/). If no nickname is specified, you will" + " use the default_nick in the configuration file. You can" + " omit the room name: you will then join the room you\'re" + " looking at (useful if you were kicked). You can also " + "provide a room_name without specifying a server, the " + "server of the room you're currently in will be used. You" + " can also provide a password to join the room.\nExamples" + ":\n/join room@server.tld\n/join room@server.tld/John\n" + "/join room2\n/join /me_again\n/join\n/join room@server" + ".tld/my_nick password\n/join / password", + shortdesc='Join a room', completion=self.completion_join) self.register_command('exit', self.command_quit, - desc=_('Just disconnect from the server and exit poezio.'), - shortdesc=_('Exit poezio.')) + desc='Just disconnect from the server and exit poezio.', + shortdesc='Exit poezio.') self.register_command('quit', self.command_quit, - desc=_('Just disconnect from the server and exit poezio.'), - shortdesc=_('Exit poezio.')) + desc='Just disconnect from the server and exit poezio.', + shortdesc='Exit poezio.') self.register_command('next', self.rotate_rooms_right, - shortdesc=_('Go to the next room.')) + shortdesc='Go to the next room.') self.register_command('prev', self.rotate_rooms_left, - shortdesc=_('Go to the previous room.')) + shortdesc='Go to the previous room.') self.register_command('win', self.command_win, - usage=_('<number or name>'), - shortdesc=_('Go to the specified room'), + usage='<number or name>', + shortdesc='Go to the specified room', completion=self.completion_win) self.commands['w'] = self.commands['win'] self.register_command('move_tab', self.command_move_tab, - usage=_('<source> <destination>'), - desc=_("Insert the <source> tab at the position of " - "<destination>. This will make the following tabs shift in" - " some cases (refer to the documentation). A tab can be " - "designated by its number or by the beginning of its " - "address. You can use \".\" as a shortcut for the current " - "tab."), - shortdesc=_('Move a tab.'), + usage='<source> <destination>', + desc="Insert the <source> tab at the position of " + "<destination>. This will make the following tabs shift in" + " some cases (refer to the documentation). A tab can be " + "designated by its number or by the beginning of its " + "address. You can use \".\" as a shortcut for the current " + "tab.", + shortdesc='Move a tab.', completion=self.completion_move_tab) self.register_command('destroy_room', self.command_destroy_room, - usage=_('[room JID]'), - desc=_('Try to destroy the room [room JID], or the current' - ' tab if it is a multi-user chat and [room JID] is ' - 'not given.'), - shortdesc=_('Destroy a room.'), + usage='[room JID]', + desc='Try to destroy the room [room JID], or the current' + ' tab if it is a multi-user chat and [room JID] is ' + 'not given.', + shortdesc='Destroy a room.', completion=None) self.register_command('show', self.command_status, - usage=_('<availability> [status message]'), - desc=_("Sets your availability and (optionally) your status " - "message. The <availability> argument is one of \"available" - ", chat, away, afk, dnd, busy, xa\" and the optional " - "[status message] argument will be your status message."), - shortdesc=_('Change your availability.'), + usage='<availability> [status message]', + desc="Sets your availability and (optionally) your status " + "message. The <availability> argument is one of \"available" + ", chat, away, afk, dnd, busy, xa\" and the optional " + "[status message] argument will be your status message.", + shortdesc='Change your availability.', completion=self.completion_status) self.commands['status'] = self.commands['show'] self.register_command('bookmark_local', self.command_bookmark_local, - usage=_("[roomname][/nick] [password]"), - desc=_("Bookmark Local: Bookmark locally the specified room " - "(you will then auto-join it on each poezio start). This" - " commands uses almost the same syntaxe as /join. Type " - "/help join for syntax examples. Note that when typing " - "\"/bookmark\" on its own, the room will be bookmarked " - "with the nickname you\'re currently using in this room " - "(instead of default_nick)"), - shortdesc=_('Bookmark a room locally.'), + usage="[roomname][/nick] [password]", + desc="Bookmark Local: Bookmark locally the specified room " + "(you will then auto-join it on each poezio start). This" + " commands uses almost the same syntaxe as /join. Type " + "/help join for syntax examples. Note that when typing " + "\"/bookmark\" on its own, the room will be bookmarked " + "with the nickname you\'re currently using in this room " + "(instead of default_nick)", + shortdesc='Bookmark a room locally.', completion=self.completion_bookmark_local) self.register_command('bookmark', self.command_bookmark, - usage=_("[roomname][/nick] [autojoin] [password]"), - desc=_("Bookmark: Bookmark online the specified room (you " - "will then auto-join it on each poezio start if autojoin" - " is specified and is 'true'). This commands uses almost" - " the same syntax as /join. Type /help join for syntax " - "examples. Note that when typing \"/bookmark\" alone, the" - " room will be bookmarked with the nickname you\'re " - "currently using in this room (instead of default_nick)."), - shortdesc=_("Bookmark a room online."), + usage="[roomname][/nick] [autojoin] [password]", + desc="Bookmark: Bookmark online the specified room (you " + "will then auto-join it on each poezio start if autojoin" + " is specified and is 'true'). This commands uses almost" + " the same syntax as /join. Type /help join for syntax " + "examples. Note that when typing \"/bookmark\" alone, the" + " room will be bookmarked with the nickname you\'re " + "currently using in this room (instead of default_nick).", + shortdesc="Bookmark a room online.", completion=self.completion_bookmark) self.register_command('set', self.command_set, - usage=_("[plugin|][section] <option> [value]"), - desc=_("Set the value of an option in your configuration file." - " You can, for example, change your default nickname by " - "doing `/set default_nick toto` or your resource with `/set" - "resource blabla`. You can also set options in specific " - "sections with `/set bindings M-i ^i` or in specific plugin" - " with `/set mpd_client| host 127.0.0.1`. `toggle` can be " - "used as a special value to toggle a boolean option."), - shortdesc=_("Set the value of an option"), + usage="[plugin|][section] <option> [value]", + desc="Set the value of an option in your configuration file." + " You can, for example, change your default nickname by " + "doing `/set default_nick toto` or your resource with `/set" + " resource blabla`. You can also set options in specific " + "sections with `/set bindings M-i ^i` or in specific plugin" + " with `/set mpd_client| host 127.0.0.1`. `toggle` can be " + "used as a special value to toggle a boolean option.", + shortdesc="Set the value of an option", completion=self.completion_set) + self.register_command('set_default', self.command_set_default, + usage="[section] <option>", + desc="Set the default value of an option. For example, " + "`/set_default resource` will reset the resource " + "option. You can also reset options in specific " + "sections by doing `/set_default section option`.", + shortdesc="Set the default value of an option", + completion=self.completion_set_default) self.register_command('toggle', self.command_toggle, - usage=_('<option>'), - desc=_('Shortcut for /set <option> toggle'), - shortdesc=_('Toggle an option'), + usage='<option>', + desc='Shortcut for /set <option> toggle', + shortdesc='Toggle an option', completion=self.completion_toggle) self.register_command('theme', self.command_theme, - usage=_('[theme name]'), - desc=_("Reload the theme defined in the config file. If theme" - "_name is provided, set that theme before reloading it."), - shortdesc=_('Load a theme'), + usage='[theme name]', + desc="Reload the theme defined in the config file. If theme" + "_name is provided, set that theme before reloading it.", + shortdesc='Load a theme', completion=self.completion_theme) self.register_command('list', self.command_list, - usage=_('[server]'), - desc=_("Get the list of public chatrooms" - " on the specified server."), - shortdesc=_('List the rooms.'), + usage='[server]', + desc="Get the list of public chatrooms" + " on the specified server.", + shortdesc='List the rooms.', completion=self.completion_list) self.register_command('message', self.command_message, - usage=_('<jid> [optional message]'), - desc=_("Open a conversation with the specified JID (even if it" - " is not in our roster), and send a message to it, if the " - "message is specified."), - shortdesc=_('Send a message'), + usage='<jid> [optional message]', + desc="Open a conversation with the specified JID (even if it" + " is not in our roster), and send a message to it, if the " + "message is specified.", + shortdesc='Send a message', completion=self.completion_message) self.register_command('version', self.command_version, usage='<jid>', - desc=_("Get the software version of the given JID (usually its" - " XMPP client and Operating System)."), - shortdesc=_('Get the software version of a JID.'), + desc="Get the software version of the given JID (usually its" + " XMPP client and Operating System).", + shortdesc='Get the software version of a JID.', completion=self.completion_version) self.register_command('server_cycle', self.command_server_cycle, - usage=_('[domain] [message]'), - desc=_('Disconnect and reconnect in all the rooms in domain.'), - shortdesc=_('Cycle a range of rooms'), + usage='[domain] [message]', + desc='Disconnect and reconnect in all the rooms in domain.', + shortdesc='Cycle a range of rooms', completion=self.completion_server_cycle) self.register_command('bind', self.command_bind, - usage=_('<key> <equ>'), - desc=_("Bind a key to another key or to a “command”. For " - "example \"/bind ^H KEY_UP\" makes Control + h do the" - " same same as the Up key."), + usage='<key> <equ>', + desc="Bind a key to another key or to a “command”. For " + "example \"/bind ^H KEY_UP\" makes Control + h do the" + " same same as the Up key.", completion=self.completion_bind, - shortdesc=_('Bind a key to another key.')) + shortdesc='Bind a key to another key.') self.register_command('load', self.command_load, - usage=_('<plugin> [<otherplugin> …]'), - shortdesc=_('Load the specified plugin(s)'), + usage='<plugin> [<otherplugin> …]', + shortdesc='Load the specified plugin(s)', completion=self.plugin_manager.completion_load) self.register_command('unload', self.command_unload, - usage=_('<plugin> [<otherplugin> …]'), - shortdesc=_('Unload the specified plugin(s)'), + usage='<plugin> [<otherplugin> …]', + shortdesc='Unload the specified plugin(s)', completion=self.plugin_manager.completion_unload) self.register_command('plugins', self.command_plugins, - shortdesc=_('Show the plugins in use.')) + shortdesc='Show the plugins in use.') self.register_command('presence', self.command_presence, - usage=_('<JID> [type] [status]'), - desc=_("Send a directed presence to <JID> and using" - " [type] and [status] if provided."), - shortdesc=_('Send a directed presence.'), + usage='<JID> [type] [status]', + desc="Send a directed presence to <JID> and using" + " [type] and [status] if provided.", + shortdesc='Send a directed presence.', completion=self.completion_presence) self.register_command('rawxml', self.command_rawxml, usage='<xml>', - shortdesc=_('Send a custom xml stanza.')) + shortdesc='Send a custom xml stanza.') self.register_command('invite', self.command_invite, - usage=_('<jid> <room> [reason]'), - desc=_('Invite jid in room with reason.'), - shortdesc=_('Invite someone in a room.'), + usage='<jid> <room> [reason]', + desc='Invite jid in room with reason.', + shortdesc='Invite someone in a room.', completion=self.completion_invite) self.register_command('invitations', self.command_invitations, - shortdesc=_('Show the pending invitations.')) + shortdesc='Show the pending invitations.') self.register_command('bookmarks', self.command_bookmarks, - shortdesc=_('Show the current bookmarks.')) + shortdesc='Show the current bookmarks.') self.register_command('remove_bookmark', self.command_remove_bookmark, usage='[jid]', - desc=_("Remove the specified bookmark, or the " - "bookmark on the current tab, if any."), - shortdesc=_('Remove a bookmark'), + desc="Remove the specified bookmark, or the " + "bookmark on the current tab, if any.", + shortdesc='Remove a bookmark', completion=self.completion_remove_bookmark) self.register_command('xml_tab', self.command_xml_tab, - shortdesc=_('Open an XML tab.')) + shortdesc='Open an XML tab.') self.register_command('runkey', self.command_runkey, - usage=_('<key>'), - shortdesc=_('Execute the action defined for <key>.'), + usage='<key>', + shortdesc='Execute the action defined for <key>.', completion=self.completion_runkey) self.register_command('self', self.command_self, - shortdesc=_('Remind you of who you are.')) + shortdesc='Remind you of who you are.') self.register_command('last_activity', self.command_last_activity, usage='<jid>', - desc=_('Informs you of the last activity of a JID.'), - shortdesc=_('Get the activity of someone.'), + desc='Informs you of the last activity of a JID.', + shortdesc='Get the activity of someone.', completion=self.completion_last_activity) self.register_command('ad-hoc', self.command_adhoc, usage='<jid>', - shortdesc=_('List available ad-hoc commands on the given jid')) + shortdesc='List available ad-hoc commands on the given jid') + self.register_command('reload', self.command_reload, + shortdesc='Reload the config. You can achieve the same by ' + 'sending SIGUSR1 to poezio.') if config.get('enable_user_activity'): self.register_command('activity', self.command_activity, usage='[<general> [specific] [text]]', - desc=_('Send your current activity to your contacts ' - '(use the completion). Nothing means ' - '"stop broadcasting an activity".'), - shortdesc=_('Send your activity.'), + desc='Send your current activity to your contacts ' + '(use the completion). Nothing means ' + '"stop broadcasting an activity".', + shortdesc='Send your activity.', completion=self.completion_activity) if config.get('enable_user_mood'): self.register_command('mood', self.command_mood, usage='[<mood> [text]]', - desc=_('Send your current mood to your contacts ' - '(use the completion). Nothing means ' - '"stop broadcasting a mood".'), - shortdesc=_('Send your mood.'), + desc='Send your current mood to your contacts ' + '(use the completion). Nothing means ' + '"stop broadcasting a mood".', + shortdesc='Send your mood.', completion=self.completion_mood) if config.get('enable_user_gaming'): self.register_command('gaming', self.command_gaming, usage='[<game name> [server address]]', - desc=_('Send your current gaming activity to ' - 'your contacts. Nothing means "stop ' - 'broadcasting a gaming activity".'), - shortdesc=_('Send your gaming activity.'), + desc='Send your current gaming activity to ' + 'your contacts. Nothing means "stop ' + 'broadcasting a gaming activity".', + shortdesc='Send your gaming activity.', completion=None) ####################### XMPP Event Handlers ################################## @@ -1899,6 +1947,7 @@ class Core(object): on_groupchat_direct_invitation = handlers.on_groupchat_direct_invitation on_groupchat_decline = handlers.on_groupchat_decline on_message = handlers.on_message + on_error_message = handlers.on_error_message on_normal_message = handlers.on_normal_message on_nick_received = handlers.on_nick_received on_gaming_event = handlers.on_gaming_event @@ -1943,9 +1992,11 @@ class Core(object): on_receipt = handlers.on_receipt on_attention = handlers.on_attention room_error = handlers.room_error + check_bookmark_storage = handlers.check_bookmark_storage outgoing_stanza = handlers.outgoing_stanza incoming_stanza = handlers.incoming_stanza validate_ssl = handlers.validate_ssl + ssl_invalid_chain = handlers.ssl_invalid_chain on_next_adhoc_step = handlers.on_next_adhoc_step on_adhoc_error = handlers.on_adhoc_error cancel_adhoc_command = handlers.cancel_adhoc_command @@ -1967,6 +2018,7 @@ class Core(object): command_destroy_room = commands.command_destroy_room command_remove_bookmark = commands.command_remove_bookmark command_set = commands.command_set + command_set_default = commands.command_set_default command_toggle = commands.command_toggle command_server_cycle = commands.command_server_cycle command_last_activity = commands.command_last_activity @@ -1986,6 +2038,7 @@ class Core(object): command_xml_tab = commands.command_xml_tab command_adhoc = commands.command_adhoc command_self = commands.command_self + command_reload = commands.command_reload completion_help = completions.completion_help completion_status = completions.completion_status completion_presence = completions.completion_presence @@ -2007,6 +2060,7 @@ class Core(object): completion_last_activity = completions.completion_last_activity completion_server_cycle = completions.completion_server_cycle completion_set = completions.completion_set + completion_set_default = completions.completion_set_default completion_toggle = completions.completion_toggle completion_bookmark_local = completions.completion_bookmark_local diff --git a/src/core/handlers.py b/src/core/handlers.py index 50dca216..828c39d1 100644 --- a/src/core/handlers.py +++ b/src/core/handlers.py @@ -12,14 +12,12 @@ import ssl import sys import time from hashlib import sha1, sha512 -from gettext import gettext as _ from os import path from slixmpp import InvalidJID -from slixmpp.stanza import Message -from slixmpp.xmlstream.stanzabase import StanzaBase +from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase +from xml.etree import ElementTree as ET -import bookmark import common import fixes import pep @@ -32,11 +30,64 @@ from config import config, CACHE_DIR from contact import Resource from logger import logger from roster import roster -from text_buffer import CorrectionError +from text_buffer import CorrectionError, AckError from theming import dump_tuple, get_theme from . commands import dumb_callback +try: + from pygments import highlight + from pygments.lexers import get_lexer_by_name + from pygments.formatters import HtmlFormatter + LEXER = get_lexer_by_name('xml') + FORMATTER = HtmlFormatter(noclasses=True) + PYGMENTS = True +except ImportError: + PYGMENTS = False + +def _join_initial_rooms(self, bookmarks): + """Join all rooms given in the iterator `bookmarks`""" + for bm in bookmarks: + if not (bm.autojoin or config.get('open_all_bookmarks')): + continue + tab = self.get_tab_by_name(bm.jid, tabs.MucTab) + nick = bm.nick if bm.nick else self.own_nick + if not tab: + self.open_new_room(bm.jid, nick, focus=False) + self.initial_joins.append(bm.jid) + histo_length = config.get('muc_history_length') + if histo_length == -1: + histo_length = None + if histo_length is not None: + histo_length = str(histo_length) + # do not join rooms that do not have autojoin + # but display them anyway + if bm.autojoin: + muc.join_groupchat(self, bm.jid, nick, + passwd=bm.password, + maxhistory=histo_length, + status=self.status.message, + show=self.status.show) + +def check_bookmark_storage(self, features): + private = 'jabber:iq:private' in features + pep_ = 'http://jabber.org/protocol/pubsub#publish' in features + self.bookmarks.available_storage['private'] = private + self.bookmarks.available_storage['pep'] = pep_ + def _join_remote_only(iq): + if iq['type'] == 'error': + type_ = iq['error']['type'] + condition = iq['error']['condition'] + if not (type_ == 'cancel' and condition == 'item-not-found'): + self.information('Unable to fetch the remote' + ' bookmarks; %s: %s' % (type_, condition), + 'Error') + return + remote_bookmarks = self.bookmarks.remote() + _join_initial_rooms(self, remote_bookmarks) + if not self.xmpp.anon and config.get('use_remote_bookmarks'): + self.bookmarks.get_remote(self.xmpp, self.information, _join_remote_only) + def on_session_start_features(self, _): """ Enable carbons & blocking on session start if wanted and possible @@ -47,11 +98,13 @@ def on_session_start_features(self, _): features = iq['disco_info']['features'] rostertab = self.get_tab_by_name('Roster', tabs.RosterInfoTab) rostertab.check_blocking(features) + rostertab.check_saslexternal(features) if (config.get('enable_carbons') and 'urn:xmpp:carbons:2' in features): self.xmpp.plugin['xep_0280'].enable() self.xmpp.add_event_handler('carbon_received', self.on_carbon_received) self.xmpp.add_event_handler('carbon_sent', self.on_carbon_sent) + self.check_bookmark_storage(features) self.xmpp.plugin['xep_0030'].get_info(jid=self.xmpp.boundjid.domain, callback=callback) @@ -173,11 +226,31 @@ def on_message(self, message): jid_from = message['from'] for tab in self.get_tabs(tabs.MucTab): if tab.name == jid_from.bare: + if message['type'] == 'chat': + return self.on_groupchat_private_message(message) + return self.on_normal_message(message) + +def on_error_message(self, message): + """ + When receiving any message with type="error" + """ + jid_from = message['from'] + for tab in self.get_tabs(tabs.MucTab): + if tab.name == jid_from.bare: if message['type'] == 'error': - return self.room_error(message, jid_from) + return self.room_error(message, jid_from.bare) else: return self.on_groupchat_private_message(message) - return self.on_normal_message(message) + tab = self.get_conversation_by_jid(message['from'], create=False) + error_msg = self.get_error_message(message, deprecated=True) + if not tab: + return self.information(error_msg, 'Error') + error = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_CHAR_NACK), + error_msg) + if not tab.nack_message('\n' + error, message['id'], message['to']): + tab.add_message(error, typ=0) + self.refresh_window() + def on_normal_message(self, message): """ @@ -185,7 +258,7 @@ def on_normal_message(self, message): muc participant) """ if message['type'] == 'error': - return self.information(self.get_error_message(message, deprecated=True), 'Error') + return elif message['type'] == 'headline' and message['body']: return self.information('%s says: %s' % (message['from'], message['body']), 'Headline') @@ -448,7 +521,7 @@ def on_groupchat_message(self, message): tab = self.get_tab_by_name(room_from, tabs.MucTab) if not tab: - self.information(_("message received for a non-existing room: %s") % (room_from)) + self.information("message received for a non-existing room: %s" % (room_from)) muc.leave_groupchat(self.xmpp, room_from, self.own_nick, msg='') return @@ -689,7 +762,10 @@ def on_subscription_request(self, presence): contact = roster.get_and_set(jid) roster.update_contact_groups(contact) contact.pending_in = True - self.information('%s wants to subscribe to your presence' % jid, 'Roster') + self.information('%s wants to subscribe to your presence, ' + 'use /accept <jid> or /deny <jid> to accept ' + 'or reject the query.' % jid, + 'Roster') self.get_tab_by_number(0).state = 'highlight' roster.modified() if isinstance(self.current_tab(), tabs.RosterInfoTab): @@ -782,7 +858,7 @@ def on_got_offline(self, presence): return jid = presence['from'] if not logger.log_roster_change(jid.bare, 'got offline'): - self.information(_('Unable to write in the log file'), 'Error') + self.information('Unable to write in the log file', 'Error') # If a resource got offline, display the message in the conversation with this # precise resource. if jid.resource: @@ -806,7 +882,7 @@ def on_got_online(self, presence): return roster.modified() if not logger.log_roster_change(jid.bare, 'got online'): - self.information(_('Unable to write in the log file'), 'Error') + self.information('Unable to write in the log file', 'Error') resource = Resource(jid.full, { 'priority': presence.get_priority() or 0, 'status': presence['status'], @@ -843,7 +919,7 @@ def on_failed_connection(self, error): """ We cannot contact the remote server """ - self.information(_("Connection to remote server failed: %s" % (error,)), _('Error')) + self.information("Connection to remote server failed: %s" % (error,), 'Error') def on_disconnected(self, event): """ @@ -854,9 +930,10 @@ def on_disconnected(self, event): roster.modified() for tab in self.get_tabs(tabs.MucTab): tab.disconnect() - self.information(_("Disconnected from server."), _('Error')) - if not self.legitimate_disconnect and config.get('auto_reconnect', False): - self.information(_("Auto-reconnecting."), _('Info')) + msg_typ = 'Error' if not self.legitimate_disconnect else 'Info' + self.information("Disconnected from server.", msg_typ) + if not self.legitimate_disconnect and config.get('auto_reconnect', True): + self.information("Auto-reconnecting.", 'Info') self.xmpp.connect() def on_stream_error(self, event): @@ -864,29 +941,29 @@ def on_stream_error(self, event): When we receive a stream error """ if event and event['text']: - self.information(_('Stream error: %s') % event['text'], _('Error')) + self.information('Stream error: %s' % event['text'], 'Error') def on_failed_all_auth(self, event): """ Authentication failed """ - self.information(_("Authentication failed (bad credentials?)."), - _('Error')) + self.information("Authentication failed (bad credentials?).", + 'Error') self.legitimate_disconnect = True def on_no_auth(self, event): """ Authentication failed (no mech) """ - self.information(_("Authentication failed, no login method available."), - _('Error')) + self.information("Authentication failed, no login method available.", + 'Error') self.legitimate_disconnect = True def on_connected(self, event): """ Remote host responded, but we are not yet authenticated """ - self.information(_("Connected to server."), 'Info') + self.information("Connected to server.", 'Info') def on_connecting(self, event): """ @@ -901,11 +978,12 @@ def on_session_start(self, event): self.connection_time = time.time() if not self.plugins_autoloaded: # Do not reload plugins on reconnection self.autoload_plugins() - self.information(_("Authentication success."), 'Info') - self.information(_("Your JID is %s") % self.xmpp.boundjid.full, 'Info') + self.information("Authentication success.", 'Info') + self.information("Your JID is %s" % self.xmpp.boundjid.full, 'Info') if not self.xmpp.anon: # request the roster self.xmpp.get_roster() + roster.update_contact_groups(self.xmpp.boundjid.bare) # send initial presence if config.get('send_initial_presence'): pres = self.xmpp.make_presence() @@ -913,37 +991,9 @@ def on_session_start(self, event): pres['status'] = self.status.message self.events.trigger('send_normal_presence', pres) pres.send() - bookmark.get_local() - def _join_initial_rooms(bookmarks): - """Join all rooms given in the iterator `bookmarks`""" - for bm in bookmarks: - if bm.autojoin or config.get('open_all_bookmarks'): - tab = self.get_tab_by_name(bm.jid, tabs.MucTab) - nick = bm.nick if bm.nick else self.own_nick - if not tab: - self.open_new_room(bm.jid, nick, False) - self.initial_joins.append(bm.jid) - histo_length = config.get('muc_history_length') - if histo_length == -1: - histo_length = None - if histo_length is not None: - histo_length = str(histo_length) - # do not join rooms that do not have autojoin - # but display them anyway - if bm.autojoin: - muc.join_groupchat(self, bm.jid, nick, - passwd=bm.password, - maxhistory=histo_length, - status=self.status.message, - show=self.status.show) - def _join_remote_only(): - remote_bookmarks = (bm for bm in bookmark.bookmarks if (bm.method in ("pep", "privatexml"))) - _join_initial_rooms(remote_bookmarks) - if not self.xmpp.anon and config.get('use_remote_bookmarks'): - bookmark.get_remote(self.xmpp, _join_remote_only) - # join all the available bookmarks. As of yet, this is just the local - # ones - _join_initial_rooms(bookmark.bookmarks) + self.bookmarks.get_local() + # join all the available bookmarks. As of yet, this is just the local ones + _join_initial_rooms(self, self.bookmarks) if config.get('enable_user_nick'): self.xmpp.plugin['xep_0172'].publish_nick(nick=self.own_nick, callback=dumb_callback) @@ -1024,12 +1074,12 @@ def on_groupchat_subject(self, message): # Do not display the message if the subject did not change or if we # receive an empty topic when joining the room. if nick_from: - tab.add_message(_("\x19%(info_col)s}%(nick)s set the subject to: %(subject)s") % + tab.add_message("\x19%(info_col)s}%(nick)s set the subject to: %(subject)s" % {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), 'nick':nick_from, 'subject':subject}, time=None, typ=2) else: - tab.add_message(_("\x19%(info_col)s}The subject is: %(subject)s") % + tab.add_message("\x19%(info_col)s}The subject is: %(subject)s" % {'subject':subject, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, time=None, typ=2) @@ -1052,7 +1102,10 @@ def on_receipt(self, message): if not conversation: return - conversation.ack_message(msg_id) + try: + conversation.ack_message(msg_id, self.xmpp.boundjid) + except AckError: + log.debug('Error while receiving an ack', exc_info=True) def on_data_form(self, message): """ @@ -1083,19 +1136,21 @@ def room_error(self, error, room_name): Display the error in the tab """ tab = self.get_tab_by_name(room_name, tabs.MucTab) + if not tab: + return error_message = self.get_error_message(error) tab.add_message(error_message, highlight=True, nickname='Error', nick_color=get_theme().COLOR_ERROR_MSG, typ=2) code = error['error']['code'] if code == '401': - msg = _('To provide a password in order to join the room, type "/join / password" (replace "password" by the real password)') + msg = 'To provide a password in order to join the room, type "/join / password" (replace "password" by the real password)' tab.add_message(msg, typ=2) if code == '409': if config.get('alternative_nickname') != '': self.command_join('%s/%s'% (tab.name, tab.own_nick+config.get('alternative_nickname'))) else: if not tab.joined: - tab.add_message(_('You can join the room with an other nick, by typing "/join /other_nick"'), typ=2) + tab.add_message('You can join the room with an other nick, by typing "/join /other_nick"', typ=2) self.refresh_window() def outgoing_stanza(self, stanza): @@ -1103,7 +1158,20 @@ def outgoing_stanza(self, stanza): We are sending a new stanza, write it in the xml buffer if needed. """ if self.xml_tab: - self.add_message_to_text_buffer(self.xml_buffer, '\x191}<--\x19o %s' % stanza) + if PYGMENTS: + xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER) + poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True).rstrip('\x19o').strip() + else: + poezio_colored = '%s' % stanza + self.add_message_to_text_buffer(self.xml_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_OUT) + try: + if self.xml_tab.match_stanza(ElementBase(ET.fromstring(stanza))): + self.add_message_to_text_buffer(self.xml_tab.filtered_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_OUT) + except: + log.debug('', exc_info=True) + if isinstance(self.current_tab(), tabs.XMLTab): self.current_tab().refresh() self.doupdate() @@ -1113,11 +1181,27 @@ def incoming_stanza(self, stanza): We are receiving a new stanza, write it in the xml buffer if needed. """ if self.xml_tab: - self.add_message_to_text_buffer(self.xml_buffer, '\x192}-->\x19o %s' % stanza) + if PYGMENTS: + xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER) + poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True).rstrip('\x19o').strip() + else: + poezio_colored = '%s' % stanza + self.add_message_to_text_buffer(self.xml_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_IN) + try: + if self.xml_tab.match_stanza(stanza): + self.add_message_to_text_buffer(self.xml_tab.filtered_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_IN) + except: + log.debug('', exc_info=True) if isinstance(self.current_tab(), tabs.XMLTab): self.current_tab().refresh() self.doupdate() +def ssl_invalid_chain(self, tb): + self.information('The certificate sent by the server is invalid.', 'Error') + self.disconnect() + def validate_ssl(self, pem): """ Check the server certificate using the slixmpp ssl_cert event @@ -1151,40 +1235,34 @@ def validate_ssl(self, pem): self.information('New certificate found (sha-2 hash:' ' %s)\nPlease validate or abort' % sha2_found_cert, 'Warning') - input = windows.YesNoInput(text="WARNING! Server certificate has changed, accept? (y/n)") - self.current_tab().input = input - input.resize(1, self.current_tab().width, self.current_tab().height-1, 0) - input.refresh() - self.doupdate() - old_loop = asyncio.get_event_loop() - new_loop = asyncio.new_event_loop() - asyncio.set_event_loop(new_loop) - new_loop.add_reader(sys.stdin, self.on_input_readable) - future = asyncio.Future() - @asyncio.coroutine - def check_input(future): - while input.value is None: - yield from asyncio.sleep(0.01) + def check_input(): self.current_tab().input = saved_input self.paused = False if input.value: self.information('Setting new certificate: old: %s, new: %s' % (cert, sha2_found_cert), 'Info') log.debug('Setting certificate to %s', sha2_found_cert) if not config.silent_set('certificate', sha2_found_cert): - self.information(_('Unable to write in the config file'), 'Error') + self.information('Unable to write in the config file', 'Error') else: self.information('You refused to validate the certificate. You are now disconnected', 'Info') - self.xmpp.disconnect() + self.disconnect() new_loop.stop() asyncio.set_event_loop(old_loop) - asyncio.async(check_input(future)) + input = windows.YesNoInput(text="WARNING! Server certificate has changed, accept? (y/n)", callback=check_input) + self.current_tab().input = input + input.resize(1, self.current_tab().width, self.current_tab().height-1, 0) + input.refresh() + self.doupdate() + old_loop = asyncio.get_event_loop() + new_loop = asyncio.new_event_loop() + asyncio.set_event_loop(new_loop) + new_loop.add_reader(sys.stdin, self.on_input_readable) + curses.beep() new_loop.run_forever() - - else: log.debug('First time. Setting certificate to %s', sha2_found_cert) if not config.silent_set('certificate', sha2_found_cert): - self.information(_('Unable to write in the config file'), 'Error') + self.information('Unable to write in the config file', 'Error') def _composing_tab_state(tab, state): """ diff --git a/src/core/structs.py b/src/core/structs.py index d97acd9f..4ce0ef43 100644 --- a/src/core/structs.py +++ b/src/core/structs.py @@ -2,39 +2,38 @@ Module defining structures useful to the core class and related methods """ import collections -from gettext import gettext as _ # http://xmpp.org/extensions/xep-0045.html#errorstatus ERROR_AND_STATUS_CODES = { - '401': _('A password is required'), - '403': _('Permission denied'), - '404': _('The room doesn’t exist'), - '405': _('Your are not allowed to create a new room'), - '406': _('A reserved nick must be used'), - '407': _('You are not in the member list'), - '409': _('This nickname is already in use or has been reserved'), - '503': _('The maximum number of users has been reached'), + '401': 'A password is required', + '403': 'Permission denied', + '404': 'The room doesn’t exist', + '405': 'Your are not allowed to create a new room', + '406': 'A reserved nick must be used', + '407': 'You are not in the member list', + '409': 'This nickname is already in use or has been reserved', + '503': 'The maximum number of users has been reached', } # http://xmpp.org/extensions/xep-0086.html DEPRECATED_ERRORS = { - '302': _('Redirect'), - '400': _('Bad request'), - '401': _('Not authorized'), - '402': _('Payment required'), - '403': _('Forbidden'), - '404': _('Not found'), - '405': _('Not allowed'), - '406': _('Not acceptable'), - '407': _('Registration required'), - '408': _('Request timeout'), - '409': _('Conflict'), - '500': _('Internal server error'), - '501': _('Feature not implemented'), - '502': _('Remote server error'), - '503': _('Service unavailable'), - '504': _('Remote server timeout'), - '510': _('Disconnected'), + '302': 'Redirect', + '400': 'Bad request', + '401': 'Not authorized', + '402': 'Payment required', + '403': 'Forbidden', + '404': 'Not found', + '405': 'Not allowed', + '406': 'Not acceptable', + '407': 'Registration required', + '408': 'Request timeout', + '409': 'Conflict', + '500': 'Internal server error', + '501': 'Feature not implemented', + '502': 'Remote server error', + '503': 'Service unavailable', + '504': 'Remote server timeout', + '510': 'Disconnected', } possible_show = {'available':None, diff --git a/src/daemon.py b/src/daemon.py index 395054a7..6325d8df 100755 --- a/src/daemon.py +++ b/src/daemon.py @@ -25,11 +25,7 @@ import subprocess import shlex import logging -try: - from subprocess import DEVNULL # Only in python >= 3.3 -except ImportError: - import os - DEVNULL = open(os.devnull, 'wb') +from subprocess import DEVNULL log = logging.getLogger(__name__) diff --git a/src/decorators.py b/src/decorators.py index 251d8749..c4ea6563 100644 --- a/src/decorators.py +++ b/src/decorators.py @@ -2,6 +2,8 @@ Module containing various decorators """ +import common + class RefreshWrapper(object): def __init__(self): self.core = None @@ -41,3 +43,97 @@ class RefreshWrapper(object): return wrap refresh_wrapper = RefreshWrapper() + +class CommandArgParser(object): + """Modify the string argument of the function into a list of strings + containing the right number of extracted arguments, or None if we don’t + have enough. + """ + @staticmethod + def raw(func): + """Just call the function with a single string, which is the original string + untouched + """ + def wrap(self, args, *a, **kw): + return func(self, args, *a, **kw) + return wrap + + @staticmethod + def ignored(func): + """ + Call the function without any argument + """ + def wrap(self, args, *a, **kw): + return func(self, *a, **kw) + return wrap + + @staticmethod + def quoted(mandatory, optional=0, defaults=[], + ignore_trailing_arguments=False): + + """The function receives a list with a number of arguments that is between + the numbers `mandatory` and `optional`. + + If the string doesn’t contain at least `mandatory` arguments, we return + None because the given arguments are invalid. + + If there are any remaining arguments after `mandatory` and `optional` + arguments have been found (and “ignore_trailing_arguments" is not True), + we happen them to the last argument of the list. + + An argument is a string (with or without whitespaces) between to quotes + ("), or a whitespace separated word (if not inside quotes). + + The argument `defaults` is a list of strings that are used when an + optional argument is missing. For example if we accept one optional + argument, zero is available but we have one value in the `defaults` + list, we use that string inplace. The `defaults` list can only + replace missing optional arguments, not mandatory ones. And it + should not contain more than `mandatory` values. Also you cannot + + Example: + This method needs at least one argument, and accepts up to 3 + arguments + + >> @command_args_parser.quoted(1, 2, ['default for first arg'], False) + >> def f(args): + >> print(args) + + >> f('coucou les amis') # We have one mandatory and two optional + ['coucou', 'les', 'amis'] + >> f('"coucou les amis" "PROUT PROUT"') # One mandator and only one optional, + # no default for the second + ['coucou les amis', 'PROUT PROUT'] + >> f('') # Not enough args for mandatory number + None + >> f('"coucou les potes"') # One mandatory, and use the default value + # for the first optional + ['coucou les potes, 'default for first arg'] + >> f('"un et demi" deux trois quatre cinq six') # We have three trailing arguments + ['un et demi', 'deux', 'trois quatre cinq six'] + + """ + def first(func): + def second(self, args, *a, **kw): + default_args = defaults + args = common.shell_split(args) + if len(args) < mandatory: + return func(self, None, *a, **kw) + res, args = args[:mandatory], args[mandatory:] + if optional == -1: + opt_args = args[:] + else: + opt_args = args[:optional] + + if opt_args: + res += opt_args + args = args[len(opt_args):] + default_args = default_args[len(opt_args):] + res += default_args + if args and res and not ignore_trailing_arguments: + res[-1] += " " + " ".join(args) + return func(self, res, *a, **kw) + return second + return first + +command_args_parser = CommandArgParser() diff --git a/src/events.py b/src/events.py index 50711022..15ef3e35 100644 --- a/src/events.py +++ b/src/events.py @@ -10,9 +10,6 @@ The list of available events is here: http://poezio.eu/doc/en/plugins.html#_poezio_events """ -import logging -log = logging.getLogger(__name__) - class EventHandler(object): """ A class keeping a list of possible events that are triggered @@ -71,9 +68,7 @@ class EventHandler(object): """ callbacks = self.events.get(name, None) if callbacks is None: - log.debug('%s: No such event.', name) return - log.debug('Event %s triggered, callbacks: %s', name, callbacks) for callback in callbacks: callback(*args, **kwargs) diff --git a/src/fixes.py b/src/fixes.py index 1c5da7c8..3840a093 100644 --- a/src/fixes.py +++ b/src/fixes.py @@ -41,12 +41,12 @@ def get_version(xmpp, jid, callback=None, **kwargs): def get_room_form(xmpp, room, callback): def _cb(result): if result["type"] == "error": - callback(None) + return callback(None) xform = result.xml.find('{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x') if xform is None: - callback(None) + return callback(None) form = xmpp.plugin['xep_0004'].buildForm(xform) - callback(form) + return callback(form) iq = xmpp.make_iq_get(ito=room) query = ET.Element('{http://jabber.org/protocol/muc#owner}query') diff --git a/src/keyboard.py b/src/keyboard.py index ec1e7d0a..ccf9e752 100755 --- a/src/keyboard.py +++ b/src/keyboard.py @@ -66,6 +66,9 @@ def get_char_list(s): if key == '^[': try: part = s.get_wch() + if part == '[': + # CTRL+arrow and meta+arrow keys have a long format + part += s.get_wch() + s.get_wch() + s.get_wch() + s.get_wch() except curses.error: pass except ValueError: # invalid input diff --git a/src/logger.py b/src/logger.py index 85c7a746..7efa8f61 100644 --- a/src/logger.py +++ b/src/logger.py @@ -25,9 +25,7 @@ import logging log = logging.getLogger(__name__) -from config import LOG_DIR - -log_dir = os.path.join(LOG_DIR, 'logs') +from config import LOG_DIR as log_dir message_log_re = re.compile(r'MR (\d{4})(\d{2})(\d{2})T' r'(\d{2}):(\d{2}):(\d{2})Z ' @@ -119,10 +117,15 @@ class Logger(object): try: fd = open(os.path.join(log_dir, jid), 'rb') - except: + except FileNotFoundError: + log.info('Non-existing log file (%s)', + os.path.join(log_dir, jid), + exc_info=True) + return + except OSError: log.error('Unable to open the log file (%s)', - os.path.join(log_dir, jid), - exc_info=True) + os.path.join(log_dir, jid), + exc_info=True) return if not fd: return diff --git a/src/multiuserchat.py b/src/multiuserchat.py index 92d09a60..80e2c706 100644 --- a/src/multiuserchat.py +++ b/src/multiuserchat.py @@ -11,7 +11,6 @@ Add some facilities that are not available on the XEP_0045 slix plugin """ -from gettext import gettext as _ from xml.etree import cElementTree as ET from common import safeJID @@ -43,10 +42,10 @@ def destroy_room(xmpp, room, reason='', altroom=''): iq.append(query) def callback(iq): if not iq or iq['type'] == 'error': - xmpp.core.information(_('Unable to destroy room %s') % room, - _('Info')) + xmpp.core.information('Unable to destroy room %s' % room, + 'Info') else: - xmpp.core.information(_('Room %s destroyed') % room, _('Info')) + xmpp.core.information('Room %s destroyed' % room, 'Info') iq.send(callback=callback) return True @@ -3,89 +3,87 @@ Collection of mappings for PEP moods/activities extracted directly from the XEP """ -from gettext import gettext as _ - MOODS = { - 'afraid': _('Afraid'), - 'amazed': _('Amazed'), - 'angry': _('Angry'), - 'amorous': _('Amorous'), - 'annoyed': _('Annoyed'), - 'anxious': _('Anxious'), - 'aroused': _('Aroused'), - 'ashamed': _('Ashamed'), - 'bored': _('Bored'), - 'brave': _('Brave'), - 'calm': _('Calm'), - 'cautious': _('Cautious'), - 'cold': _('Cold'), - 'confident': _('Confident'), - 'confused': _('Confused'), - 'contemplative': _('Contemplative'), - 'contented': _('Contented'), - 'cranky': _('Cranky'), - 'crazy': _('Crazy'), - 'creative': _('Creative'), - 'curious': _('Curious'), - 'dejected': _('Dejected'), - 'depressed': _('Depressed'), - 'disappointed': _('Disappointed'), - 'disgusted': _('Disgusted'), - 'dismayed': _('Dismayed'), - 'distracted': _('Distracted'), - 'embarrassed': _('Embarrassed'), - 'envious': _('Envious'), - 'excited': _('Excited'), - 'flirtatious': _('Flirtatious'), - 'frustrated': _('Frustrated'), - 'grumpy': _('Grumpy'), - 'guilty': _('Guilty'), - 'happy': _('Happy'), - 'hopeful': _('Hopeful'), - 'hot': _('Hot'), - 'humbled': _('Humbled'), - 'humiliated': _('Humiliated'), - 'hungry': _('Hungry'), - 'hurt': _('Hurt'), - 'impressed': _('Impressed'), - 'in_awe': _('In awe'), - 'in_love': _('In love'), - 'indignant': _('Indignant'), - 'interested': _('Interested'), - 'intoxicated': _('Intoxicated'), - 'invincible': _('Invincible'), - 'jealous': _('Jealous'), - 'lonely': _('Lonely'), - 'lucky': _('Lucky'), - 'mean': _('Mean'), - 'moody': _('Moody'), - 'nervous': _('Nervous'), - 'neutral': _('Neutral'), - 'offended': _('Offended'), - 'outraged': _('Outraged'), - 'playful': _('Playful'), - 'proud': _('Proud'), - 'relaxed': _('Relaxed'), - 'relieved': _('Relieved'), - 'remorseful': _('Remorseful'), - 'restless': _('Restless'), - 'sad': _('Sad'), - 'sarcastic': _('Sarcastic'), - 'serious': _('Serious'), - 'shocked': _('Shocked'), - 'shy': _('Shy'), - 'sick': _('Sick'), - 'sleepy': _('Sleepy'), - 'spontaneous': _('Spontaneous'), - 'stressed': _('Stressed'), - 'strong': _('Strong'), - 'surprised': _('Surprised'), - 'thankful': _('Thankful'), - 'thirsty': _('Thirsty'), - 'tired': _('Tired'), - 'undefined': _('Undefined'), - 'weak': _('Weak'), - 'worried': _('Worried') + 'afraid': 'Afraid', + 'amazed': 'Amazed', + 'angry': 'Angry', + 'amorous': 'Amorous', + 'annoyed': 'Annoyed', + 'anxious': 'Anxious', + 'aroused': 'Aroused', + 'ashamed': 'Ashamed', + 'bored': 'Bored', + 'brave': 'Brave', + 'calm': 'Calm', + 'cautious': 'Cautious', + 'cold': 'Cold', + 'confident': 'Confident', + 'confused': 'Confused', + 'contemplative': 'Contemplative', + 'contented': 'Contented', + 'cranky': 'Cranky', + 'crazy': 'Crazy', + 'creative': 'Creative', + 'curious': 'Curious', + 'dejected': 'Dejected', + 'depressed': 'Depressed', + 'disappointed': 'Disappointed', + 'disgusted': 'Disgusted', + 'dismayed': 'Dismayed', + 'distracted': 'Distracted', + 'embarrassed': 'Embarrassed', + 'envious': 'Envious', + 'excited': 'Excited', + 'flirtatious': 'Flirtatious', + 'frustrated': 'Frustrated', + 'grumpy': 'Grumpy', + 'guilty': 'Guilty', + 'happy': 'Happy', + 'hopeful': 'Hopeful', + 'hot': 'Hot', + 'humbled': 'Humbled', + 'humiliated': 'Humiliated', + 'hungry': 'Hungry', + 'hurt': 'Hurt', + 'impressed': 'Impressed', + 'in_awe': 'In awe', + 'in_love': 'In love', + 'indignant': 'Indignant', + 'interested': 'Interested', + 'intoxicated': 'Intoxicated', + 'invincible': 'Invincible', + 'jealous': 'Jealous', + 'lonely': 'Lonely', + 'lucky': 'Lucky', + 'mean': 'Mean', + 'moody': 'Moody', + 'nervous': 'Nervous', + 'neutral': 'Neutral', + 'offended': 'Offended', + 'outraged': 'Outraged', + 'playful': 'Playful', + 'proud': 'Proud', + 'relaxed': 'Relaxed', + 'relieved': 'Relieved', + 'remorseful': 'Remorseful', + 'restless': 'Restless', + 'sad': 'Sad', + 'sarcastic': 'Sarcastic', + 'serious': 'Serious', + 'shocked': 'Shocked', + 'shy': 'Shy', + 'sick': 'Sick', + 'sleepy': 'Sleepy', + 'spontaneous': 'Spontaneous', + 'stressed': 'Stressed', + 'strong': 'Strong', + 'surprised': 'Surprised', + 'thankful': 'Thankful', + 'thirsty': 'Thirsty', + 'tired': 'Tired', + 'undefined': 'Undefined', + 'weak': 'Weak', + 'worried': 'Worried' } @@ -93,131 +91,131 @@ MOODS = { ACTIVITIES = { 'doing_chores': { - 'category': _('Doing_chores'), - - 'buying_groceries': _('Buying groceries'), - 'cleaning': _('Cleaning'), - 'cooking': _('Cooking'), - 'doing_maintenance': _('Doing maintenance'), - 'doing_the_dishes': _('Doing the dishes'), - 'doing_the_laundry': _('Doing the laundry'), - 'gardening': _('Gardening'), - 'running_an_errand': _('Running an errand'), - 'walking_the_dog': _('Walking the dog'), - 'other': _('Other'), + 'category': 'Doing_chores', + + 'buying_groceries': 'Buying groceries', + 'cleaning': 'Cleaning', + 'cooking': 'Cooking', + 'doing_maintenance': 'Doing maintenance', + 'doing_the_dishes': 'Doing the dishes', + 'doing_the_laundry': 'Doing the laundry', + 'gardening': 'Gardening', + 'running_an_errand': 'Running an errand', + 'walking_the_dog': 'Walking the dog', + 'other': 'Other', }, 'drinking': { - 'category': _('Drinking'), + 'category': 'Drinking', - 'having_a_beer': _('Having a beer'), - 'having_coffee': _('Having coffee'), - 'having_tea': _('Having tea'), - 'other': _('Other'), + 'having_a_beer': 'Having a beer', + 'having_coffee': 'Having coffee', + 'having_tea': 'Having tea', + 'other': 'Other', }, 'eating': { - 'category':_('Eating'), + 'category':'Eating', - 'having_breakfast': _('Having breakfast'), - 'having_a_snack': _('Having a snack'), - 'having_dinner': _('Having dinner'), - 'having_lunch': _('Having lunch'), - 'other': _('Other'), + 'having_breakfast': 'Having breakfast', + 'having_a_snack': 'Having a snack', + 'having_dinner': 'Having dinner', + 'having_lunch': 'Having lunch', + 'other': 'Other', }, 'exercising': { - 'category': _('Exercising'), - - 'cycling': _('Cycling'), - 'dancing': _('Dancing'), - 'hiking': _('Hiking'), - 'jogging': _('Jogging'), - 'playing_sports': _('Playing sports'), - 'running': _('Running'), - 'skiing': _('Skiing'), - 'swimming': _('Swimming'), - 'working_out': _('Working out'), - 'other': _('Other'), + 'category': 'Exercising', + + 'cycling': 'Cycling', + 'dancing': 'Dancing', + 'hiking': 'Hiking', + 'jogging': 'Jogging', + 'playing_sports': 'Playing sports', + 'running': 'Running', + 'skiing': 'Skiing', + 'swimming': 'Swimming', + 'working_out': 'Working out', + 'other': 'Other', }, 'grooming': { - 'category': _('Grooming'), - - 'at_the_spa': _('At the spa'), - 'brushing_teeth': _('Brushing teeth'), - 'getting_a_haircut': _('Getting a haircut'), - 'shaving': _('Shaving'), - 'taking_a_bath': _('Taking a bath'), - 'taking_a_shower': _('Taking a shower'), - 'other': _('Other'), + 'category': 'Grooming', + + 'at_the_spa': 'At the spa', + 'brushing_teeth': 'Brushing teeth', + 'getting_a_haircut': 'Getting a haircut', + 'shaving': 'Shaving', + 'taking_a_bath': 'Taking a bath', + 'taking_a_shower': 'Taking a shower', + 'other': 'Other', }, 'having_appointment': { - 'category': _('Having appointment'), + 'category': 'Having appointment', - 'other': _('Other'), + 'other': 'Other', }, 'inactive': { - 'category': _('Inactive'), - - 'day_off': _('Day_off'), - 'hanging_out': _('Hanging out'), - 'hiding': _('Hiding'), - 'on_vacation': _('On vacation'), - 'praying': _('Praying'), - 'scheduled_holiday': _('Scheduled holiday'), - 'sleeping': _('Sleeping'), - 'thinking': _('Thinking'), - 'other': _('Other'), + 'category': 'Inactive', + + 'day_off': 'Day_off', + 'hanging_out': 'Hanging out', + 'hiding': 'Hiding', + 'on_vacation': 'On vacation', + 'praying': 'Praying', + 'scheduled_holiday': 'Scheduled holiday', + 'sleeping': 'Sleeping', + 'thinking': 'Thinking', + 'other': 'Other', }, 'relaxing': { - 'category': _('Relaxing'), - - 'fishing': _('Fishing'), - 'gaming': _('Gaming'), - 'going_out': _('Going out'), - 'partying': _('Partying'), - 'reading': _('Reading'), - 'rehearsing': _('Rehearsing'), - 'shopping': _('Shopping'), - 'smoking': _('Smoking'), - 'socializing': _('Socializing'), - 'sunbathing': _('Sunbathing'), - 'watching_a_movie': _('Watching a movie'), - 'watching_tv': _('Watching tv'), - 'other': _('Other'), + 'category': 'Relaxing', + + 'fishing': 'Fishing', + 'gaming': 'Gaming', + 'going_out': 'Going out', + 'partying': 'Partying', + 'reading': 'Reading', + 'rehearsing': 'Rehearsing', + 'shopping': 'Shopping', + 'smoking': 'Smoking', + 'socializing': 'Socializing', + 'sunbathing': 'Sunbathing', + 'watching_a_movie': 'Watching a movie', + 'watching_tv': 'Watching tv', + 'other': 'Other', }, 'talking': { - 'category': _('Talking'), + 'category': 'Talking', - 'in_real_life': _('In real life'), - 'on_the_phone': _('On the phone'), - 'on_video_phone': _('On video phone'), - 'other': _('Other'), + 'in_real_life': 'In real life', + 'on_the_phone': 'On the phone', + 'on_video_phone': 'On video phone', + 'other': 'Other', }, 'traveling': { - 'category': _('Traveling'), - - 'commuting': _('Commuting'), - 'driving': _('Driving'), - 'in_a_car': _('In a car'), - 'on_a_bus': _('On a bus'), - 'on_a_plane': _('On a plane'), - 'on_a_train': _('On a train'), - 'on_a_trip': _('On a trip'), - 'walking': _('Walking'), - 'cycling': _('Cycling'), - 'other': _('Other'), + 'category': 'Traveling', + + 'commuting': 'Commuting', + 'driving': 'Driving', + 'in_a_car': 'In a car', + 'on_a_bus': 'On a bus', + 'on_a_plane': 'On a plane', + 'on_a_train': 'On a train', + 'on_a_trip': 'On a trip', + 'walking': 'Walking', + 'cycling': 'Cycling', + 'other': 'Other', }, 'undefined': { - 'category': _('Undefined'), + 'category': 'Undefined', - 'other': _('Other'), + 'other': 'Other', }, 'working': { - 'category': _('Working'), + 'category': 'Working', - 'coding': _('Coding'), - 'in_a_meeting': _('In a meeting'), - 'writing': _('Writing'), - 'studying': _('Studying'), - 'other': _('Other'), + 'coding': 'Coding', + 'in_a_meeting': 'In a meeting', + 'writing': 'Writing', + 'studying': 'Studying', + 'other': 'Other', } } diff --git a/src/plugin.py b/src/plugin.py index eb2a89e3..eca6baf2 100644 --- a/src/plugin.py +++ b/src/plugin.py @@ -19,12 +19,12 @@ class PluginConfig(config.Config): They are accessible inside the plugin with self.config and behave like the core Config object. """ - def __init__(self, filename, module_name): - config.Config.__init__(self, filename) + def __init__(self, filename, module_name, default=None): + config.Config.__init__(self, filename, default=default) self.module_name = module_name self.read() - def get(self, option, default, section=None): + def get(self, option, default=None, section=None): if not section: section = self.module_name return config.Config.get(self, option, default, section) @@ -80,6 +80,7 @@ class SafetyMetaclass(type): if inspect.stack()[1][1] == inspect.getfile(f): raise elif SafetyMetaclass.core: + log.error('Error in a plugin', exc_info=True) SafetyMetaclass.core.information(traceback.format_exc()) return None return helper @@ -364,12 +365,19 @@ class BasePlugin(object, metaclass=SafetyMetaclass): Class that all plugins derive from. """ + default_config = None + def __init__(self, plugin_api, core, plugins_conf_dir): self.core = core # More hack; luckily we'll never have more than one core object SafetyMetaclass.core = core conf = os.path.join(plugins_conf_dir, self.__module__+'.cfg') - self.config = PluginConfig(conf, self.__module__) + try: + self.config = PluginConfig(conf, self.__module__, + default=self.default_config) + except Exception: + log.debug('Error while creating the plugin config', exc_info=True) + self.config = PluginConfig(conf, self.__module__) self._api = plugin_api[self.name] self.init() diff --git a/src/plugin_manager.py b/src/plugin_manager.py index d4cc7384..549753a9 100644 --- a/src/plugin_manager.py +++ b/src/plugin_manager.py @@ -5,12 +5,9 @@ the API together. Defines also a bunch of variables related to the plugin env. """ -import imp import os from os import path import logging -from gettext import gettext as _ -from sys import version_info import core import tabs @@ -44,9 +41,8 @@ class PluginManager(object): self.tab_keys = {} self.roster_elements = {} - if version_info[1] >= 3: # 3.3 & > - from importlib import machinery - self.finder = machinery.PathFinder() + from importlib import machinery + self.finder = machinery.PathFinder() self.initial_set_plugins_dir() self.initial_set_plugins_conf_dir() @@ -70,29 +66,16 @@ class PluginManager(object): try: module = None - if version_info[1] < 3: # < 3.3 - if name in self.modules: - imp.acquire_lock() - module = imp.reload(self.modules[name]) - else: - file, filename, info = imp.find_module(name, - self.load_path) - imp.acquire_lock() - module = imp.load_module(name, file, filename, info) - else: # 3.3 & > - loader = self.finder.find_module(name, self.load_path) - if not loader: - self.core.information('Could not find plugin: %s' % name) - return - module = loader.load_module() - + loader = self.finder.find_module(name, self.load_path) + if not loader: + self.core.information('Could not find plugin: %s' % name) + return + module = loader.load_module() except Exception as e: log.debug("Could not load plugin %s", name, exc_info=True) self.core.information("Could not load plugin %s: %s" % (name, e), 'Error') finally: - if version_info[1] < 3 and imp.lock_held(): - imp.release_lock() if not module: return @@ -109,8 +92,8 @@ class PluginManager(object): except Exception as e: log.error('Error while loading the plugin %s', name, exc_info=True) if notify: - self.core.information(_('Unable to load the plugin %s: %s') % - (name, e), + self.core.information('Unable to load the plugin %s: %s' % + (name, e), 'Error') self.unload(name, notify=False) else: @@ -147,8 +130,8 @@ class PluginManager(object): self.core.information('Plugin %s unloaded' % name, 'Info') except Exception as e: log.debug("Could not unload plugin %s", name, exc_info=True) - self.core.information(_("Could not unload plugin %s: %s") % - (name, e), + self.core.information("Could not unload plugin %s: %s" % + (name, e), 'Error') def add_command(self, module_name, name, handler, help, @@ -157,7 +140,7 @@ class PluginManager(object): Add a global command. """ if name in self.core.commands: - raise Exception(_("Command '%s' already exists") % (name,)) + raise Exception("Command '%s' already exists" % (name,)) commands = self.commands[module_name] commands[name] = core.Command(handler, help, completion, short, usage) @@ -244,7 +227,7 @@ class PluginManager(object): already exists. """ if key in self.core.key_func: - raise Exception(_("Key '%s' already exists") % (key,)) + raise Exception("Key '%s' already exists" % (key,)) keys = self.keys[module_name] keys[key] = handler self.core.key_func[key] = handler @@ -295,7 +278,7 @@ class PluginManager(object): except: pass except OSError as e: - self.core.information(_('Completion failed: %s' % e), 'Error') + self.core.information('Completion failed: %s' % e, 'Error') return plugins_files = [name[:-3] for name in names if name.endswith('.py') and name != '__init__.py' and not name.startswith('.')] diff --git a/src/poezio.py b/src/poezio.py index 9a26e135..7a83f510 100644 --- a/src/poezio.py +++ b/src/poezio.py @@ -19,6 +19,29 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__))) import singleton +def test_curses(): + """ + Check if the system ncurses linked with python has unicode capabilities. + """ + import curses + if hasattr(curses, 'unget_wch'): + return True + print("""\ +ERROR: The current python executable is linked with a ncurses version that \ +has no unicode capabilities. + +This could mean that: + - python was built on a system where readline is linked against \ +libncurses and not libncursesw + - python was built without ncursesw headers available + +Please file a bug for your distribution or fix that on your system and then \ +recompile python. +Poezio is currently unable to read your input or draw its interface properly,\ + so it will now exit.""") + return False + + def main(): """ Enter point @@ -36,6 +59,10 @@ def main(): from config import options + if options.check_config: + config.check_config() + sys.exit(0) + import theming theming.update_themes_dir() @@ -75,4 +102,7 @@ def main(): pass if __name__ == '__main__': - main() + if test_curses(): + main() + else: + sys.exit(1) diff --git a/src/tabs/__init__.py b/src/tabs/__init__.py index eaf41a2f..d0a881a6 100644 --- a/src/tabs/__init__.py +++ b/src/tabs/__init__.py @@ -10,3 +10,4 @@ from . listtab import ListTab from . muclisttab import MucListTab from . adhoc_commands_list import AdhocCommandsListTab from . data_forms import DataFormsTab +from . bookmarkstab import BookmarksTab diff --git a/src/tabs/adhoc_commands_list.py b/src/tabs/adhoc_commands_list.py index 7f5abf6a..10ebf22b 100644 --- a/src/tabs/adhoc_commands_list.py +++ b/src/tabs/adhoc_commands_list.py @@ -4,8 +4,6 @@ select one of them and start executing it, or just close the tab and do nothing. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -20,7 +18,7 @@ class AdhocCommandsListTab(ListTab): def __init__(self, jid): ListTab.__init__(self, jid.full, "“Enter”: execute selected command.", - _('Ad-hoc commands of JID %s (Loading)') % jid, + 'Ad-hoc commands of JID %s (Loading)' % jid, (('Node', 0), ('Description', 1))) self.key_func['^M'] = self.execute_selected_command @@ -50,7 +48,7 @@ class AdhocCommandsListTab(ListTab): yield item items = [(item['node'], item['name'] or '', item['jid']) for item in get_items()] self.listview.set_lines(items) - self.info_header.message = _('Ad-hoc commands of JID %s') % self.name + self.info_header.message = 'Ad-hoc commands of JID %s' % self.name if self.core.current_tab() is self: self.refresh() else: diff --git a/src/tabs/basetabs.py b/src/tabs/basetabs.py index 0a55640c..30ddf239 100644 --- a/src/tabs/basetabs.py +++ b/src/tabs/basetabs.py @@ -13,8 +13,6 @@ This module also defines ChatTabs, the parent class for all tabs revolving around chats. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -35,7 +33,7 @@ from decorators import refresh_wrapper from logger import logger from text_buffer import TextBuffer from theming import get_theme, dump_tuple - +from decorators import command_args_parser # getters for tab colors (lambdas, so that they are dynamic) STATE_COLORS = { @@ -254,7 +252,6 @@ class Tab(object): return False # There's no completion function else: return command[2](the_input) - return True return False def execute_command(self, provided_text): @@ -282,14 +279,15 @@ class Tab(object): if self.missing_command_callback is not None: error_handled = self.missing_command_callback(low) if not error_handled: - self.core.information(_("Unknown command (%s)") % - (command), - _('Error')) + self.core.information("Unknown command (%s)" % + (command), + 'Error') if command in ('correct', 'say'): # hack arg = xhtml.convert_simple_to_full_colors(arg) else: arg = xhtml.clean_text_simple(arg) if func: + self.input.reset_completion() func(arg) return True else: @@ -455,16 +453,16 @@ class ChatTab(Tab): self.key_func['M-/'] = self.last_words_completion self.key_func['^M'] = self.on_enter self.register_command('say', self.command_say, - usage=_('<message>'), - shortdesc=_('Send the message.')) + usage='<message>', + shortdesc='Send the message.') self.register_command('xhtml', self.command_xhtml, - usage=_('<custom xhtml>'), - shortdesc=_('Send custom XHTML.')) + usage='<custom xhtml>', + shortdesc='Send custom XHTML.') self.register_command('clear', self.command_clear, - shortdesc=_('Clear the current buffer.')) + shortdesc='Clear the current buffer.') self.register_command('correct', self.command_correct, - desc=_('Fix the last message with whatever you want.'), - shortdesc=_('Correct the last message.'), + desc='Fix the last message with whatever you want.', + shortdesc='Correct the last message.', completion=self.completion_correct) self.chat_state = None self.update_commands() @@ -492,7 +490,7 @@ class ChatTab(Tab): """ name = safeJID(self.name).bare if not logger.log_message(name, nickname, txt, date=time, typ=typ): - self.core.information(_('Unable to write in the log file'), 'Error') + self.core.information('Unable to write in the log file', 'Error') def add_message(self, txt, time=None, nickname=None, forced_user=None, nick_color=None, identifier=None, jid=None, history=None, @@ -544,11 +542,12 @@ class ChatTab(Tab): self.command_say(xhtml.convert_simple_to_full_colors(txt)) self.cancel_paused_delay() - def command_xhtml(self, arg): + @command_args_parser.raw + def command_xhtml(self, xhtml): """" /xhtml <custom xhtml> """ - message = self.generate_xhtml_message(arg) + message = self.generate_xhtml_message(xhtml) if message: message.send() @@ -573,7 +572,7 @@ class ChatTab(Tab): return self.name @refresh_wrapper.always - def command_clear(self, args): + def command_clear(self, ignored): """ /clear """ @@ -637,6 +636,7 @@ class ChatTab(Tab): self.core.remove_timed_event(self.timed_event_paused) self.timed_event_paused = None + @command_args_parser.raw def command_correct(self, line): """ /correct <fixed message> @@ -645,7 +645,7 @@ class ChatTab(Tab): self.core.command_help('correct') return if not self.last_sent_message: - self.core.information(_('There is no message to correct.')) + self.core.information('There is no message to correct.') return self.command_say(line, correct=True) @@ -672,6 +672,7 @@ class ChatTab(Tab): if self.text_win.pos != 0: self.state = 'scrolled' + @command_args_parser.raw def command_say(self, line, correct=False): pass @@ -707,20 +708,67 @@ class OneToOneTab(ChatTab): # change this to True or False when # we know that the remote user wants chatstates, or not. # None means we don’t know yet, and we send only "active" chatstates - self.remote_wants_chatstates = None + self._remote_wants_chatstates = None self.remote_supports_attention = True self.remote_supports_receipts = True self.check_features() - def ack_message(self, msg_id): + @property + def remote_wants_chatstates(self): + return self._remote_wants_chatstates + + @remote_wants_chatstates.setter + def remote_wants_chatstates(self, value): + old_value = self._remote_wants_chatstates + self._remote_wants_chatstates = value + if (old_value is None and value != None) or \ + (old_value != value and value != None): + ok = get_theme().CHAR_OK + nope = get_theme().CHAR_EMPTY + support = ok if value else nope + if value: + msg = '\x19%s}Contact supports chat states [%s].' + else: + msg = '\x19%s}Contact does not support chat states [%s].' + color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + msg = msg % (color, support) + self.add_message(msg, typ=0) + self.core.refresh_window() + + def ack_message(self, msg_id, msg_jid): """ Ack a message """ - new_msg = self._text_buffer.ack_message(msg_id) + new_msg = self._text_buffer.ack_message(msg_id, msg_jid) if new_msg: self.text_win.modify_message(msg_id, new_msg) self.core.refresh_window() + def nack_message(self, error, msg_id, msg_jid): + """ + Ack a message + """ + new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid) + if new_msg: + self.text_win.modify_message(msg_id, new_msg) + self.core.refresh_window() + return True + return False + + @command_args_parser.raw + def command_xhtml(self, xhtml_data): + message = self.generate_xhtml_message(xhtml_data) + if message: + if self.remote_supports_receipts: + message._add_receipt = True + if self.remote_wants_chatstates: + message['chat_sate'] = 'active' + message.send() + body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True) + self._text_buffer.add_message(body, nickname=self.core.own_nick, + identifier=message['id'],) + self.refresh() + def check_features(self): "check the features supported by the other party" if safeJID(self.get_dest_jid()).resource: @@ -728,8 +776,9 @@ class OneToOneTab(ChatTab): jid=self.get_dest_jid(), timeout=5, callback=self.features_checked) - def command_attention(self, message=''): - "/attention [message]" + @command_args_parser.raw + def command_attention(self, message): + """/attention [message]""" if message is not '': self.command_say(message, attention=True) else: @@ -738,6 +787,7 @@ class OneToOneTab(ChatTab): msg['attention'] = True msg.send() + @command_args_parser.raw def command_say(self, line, correct=False, attention=False): pass @@ -746,11 +796,11 @@ class OneToOneTab(ChatTab): return False if command_name == 'correct': - feature = _('message correction') + feature = 'message correction' elif command_name == 'attention': - feature = _('attention requests') - msg = _('%s does not support %s, therefore the /%s ' - 'command is currently disabled in this tab.') + feature = 'attention requests' + msg = ('%s does not support %s, therefore the /%s ' + 'command is currently disabled in this tab.') msg = msg % (self.name, feature, command_name) self.core.information(msg, 'Info') return True @@ -760,11 +810,11 @@ class OneToOneTab(ChatTab): if 'urn:xmpp:attention:0' in features: self.remote_supports_attention = True self.register_command('attention', self.command_attention, - usage=_('[message]'), - shortdesc=_('Request the attention.'), - desc=_('Attention: Request the attention of ' - 'the contact. Can also send a message' - ' along with the attention.')) + usage='[message]', + shortdesc='Request the attention.', + desc='Attention: Request the attention of ' + 'the contact. Can also send a message' + ' along with the attention.') else: self.remote_supports_attention = False return self.remote_supports_attention @@ -776,8 +826,8 @@ class OneToOneTab(ChatTab): del self.commands['correct'] elif not 'correct' in self.commands: self.register_command('correct', self.command_correct, - desc=_('Fix the last message with whatever you want.'), - shortdesc=_('Correct the last message.'), + desc='Fix the last message with whatever you want.', + shortdesc='Correct the last message.', completion=self.completion_correct) return 'correct' in self.commands @@ -814,8 +864,8 @@ class OneToOneTab(ChatTab): attention = ok if attention else nope receipts = ok if receipts else nope - msg = _('\x19%s}Contact supports: correction [%s], ' - 'attention [%s], receipts [%s].') + msg = ('\x19%s}Contact supports: correction [%s], ' + 'attention [%s], receipts [%s].') color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) msg = msg % (color, correct, attention, receipts) self.add_message(msg, typ=0) diff --git a/src/tabs/bookmarkstab.py b/src/tabs/bookmarkstab.py new file mode 100644 index 00000000..7f5069ea --- /dev/null +++ b/src/tabs/bookmarkstab.py @@ -0,0 +1,145 @@ +""" +Defines the data-forms Tab +""" + +import logging +log = logging.getLogger(__name__) + +import windows +from bookmarks import Bookmark, BookmarkList, stanza_storage +from tabs import Tab +from common import safeJID + + +class BookmarksTab(Tab): + """ + A tab displaying lines of bookmarks, each bookmark having + a 4 widgets to set the jid/password/autojoin/storage method + """ + plugin_commands = {} + def __init__(self, bookmarks: BookmarkList): + Tab.__init__(self) + self.name = "Bookmarks" + self.bookmarks = bookmarks + self.new_bookmarks = [] + self.removed_bookmarks = [] + self.header_win = windows.ColumnHeaderWin(('room@server/nickname', + 'password', + 'autojoin', + 'storage')) + self.bookmarks_win = windows.BookmarksWin(self.bookmarks, + self.height-4, + self.width, 1, 0) + self.help_win = windows.HelpText('Ctrl+Y: save, Ctrl+G: cancel, ' + '↑↓: change lines, tab: change ' + 'column, M-a: add bookmark, C-k' + ': delete bookmark') + self.info_header = windows.BookmarksInfoWin() + self.key_func['KEY_UP'] = self.bookmarks_win.go_to_previous_line_input + self.key_func['KEY_DOWN'] = self.bookmarks_win.go_to_next_line_input + self.key_func['^I'] = self.bookmarks_win.go_to_next_horizontal_input + self.key_func['^G'] = self.on_cancel + self.key_func['^Y'] = self.on_save + self.key_func['M-a'] = self.add_bookmark + self.key_func['^K'] = self.del_bookmark + self.resize() + self.update_commands() + + def add_bookmark(self): + new_bookmark = Bookmark(safeJID('room@example.tld/nick'), method='local') + self.new_bookmarks.append(new_bookmark) + self.bookmarks_win.add_bookmark(new_bookmark) + + def del_bookmark(self): + current = self.bookmarks_win.del_current_bookmark() + if current in self.new_bookmarks: + self.new_bookmarks.remove(current) + else: + self.removed_bookmarks.append(current) + + def on_cancel(self): + self.core.close_tab() + return True + + def on_save(self): + self.bookmarks_win.save() + if find_duplicates(self.new_bookmarks): + self.core.information('Duplicate bookmarks in list (saving aborted)', 'Error') + return + for bm in self.new_bookmarks: + if safeJID(bm.jid): + if not self.bookmarks[bm.jid]: + self.bookmarks.append(bm) + else: + self.core.information('Invalid JID for bookmark: %s/%s' % (bm.jid, bm.nick), 'Error') + return + + for bm in self.removed_bookmarks: + if bm in self.bookmarks: + self.bookmarks.remove(bm) + + def send_cb(success): + if success: + self.core.information('Bookmarks saved.', 'Info') + else: + self.core.information('Remote bookmarks not saved.', 'Error') + log.debug('alerte %s', str(stanza_storage(self.bookmarks.bookmarks))) + self.bookmarks.save(self.core.xmpp, callback=send_cb) + self.core.close_tab() + return True + + def on_input(self, key, raw=False): + if key in self.key_func: + res = self.key_func[key]() + if res: + return res + self.bookmarks_win.refresh_current_input() + else: + self.bookmarks_win.on_input(key) + + def resize(self): + self.need_resize = False + self.header_win.resize_columns({ + 'room@server/nickname': self.width//3, + 'password': self.width//3, + 'autojoin': self.width//6, + 'storage': self.width//6 + }) + info_height = self.core.information_win_size + tab_height = Tab.tab_win_height() + self.header_win.resize(1, self.width, 0, 0) + self.bookmarks_win.resize(self.height - 3 - tab_height - info_height, + self.width, 1, 0) + self.help_win.resize(1, self.width, self.height - 1, 0) + self.info_header.resize(1, self.width, + self.height - 2 - tab_height - info_height, 0) + + def on_info_win_size_changed(self): + if self.core.information_win_size >= self.height - 3: + return + info_height = self.core.information_win_size + tab_height = Tab.tab_win_height() + self.bookmarks_win.resize(self.height - 3 - tab_height - info_height, + self.width, 1, 0) + self.info_header.resize(1, self.width, + self.height - 2 - tab_height - info_height, 0) + + def refresh(self): + if self.need_resize: + self.resize() + self.header_win.refresh() + self.refresh_tab_win() + self.help_win.refresh() + self.info_header.refresh(self.bookmarks.preferred) + self.info_win.refresh() + self.bookmarks_win.refresh() + + +def find_duplicates(bm_list): + jids = set() + for bookmark in bm_list: + if bookmark.jid in jids: + return True + jids.add(bookmark.jid) + return False + diff --git a/src/tabs/conversationtab.py b/src/tabs/conversationtab.py index 52c503d7..1d8c60a4 100644 --- a/src/tabs/conversationtab.py +++ b/src/tabs/conversationtab.py @@ -11,8 +11,6 @@ There are two different instances of a ConversationTab: the time. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -29,6 +27,7 @@ from config import config from decorators import refresh_wrapper from roster import roster from theming import get_theme, dump_tuple +from decorators import command_args_parser class ConversationTab(OneToOneTab): """ @@ -53,18 +52,18 @@ class ConversationTab(OneToOneTab): self.key_func['^I'] = self.completion # commands self.register_command('unquery', self.command_unquery, - shortdesc=_('Close the tab.')) + shortdesc='Close the tab.') self.register_command('close', self.command_unquery, - shortdesc=_('Close the tab.')) + shortdesc='Close the tab.') self.register_command('version', self.command_version, - desc=_('Get the software version of the current interlocutor (usually its XMPP client and Operating System).'), - shortdesc=_('Get the software version of the user.')) + desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).', + shortdesc='Get the software version of the user.') self.register_command('info', self.command_info, - shortdesc=_('Get the status of the contact.')) + shortdesc='Get the status of the contact.') self.register_command('last_activity', self.command_last_activity, - usage=_('[jid]'), - desc=_('Get the last activity of the given or the current contact.'), - shortdesc=_('Get the activity.'), + usage='[jid]', + desc='Get the last activity of the given or the current contact.', + shortdesc='Get the activity.', completion=self.core.completion_last_activity) self.resize() self.update_commands() @@ -88,6 +87,7 @@ class ConversationTab(OneToOneTab): def completion(self): self.complete_commands(self.input) + @command_args_parser.raw def command_say(self, line, attention=False, correct=False): msg = self.core.xmpp.make_message(self.get_dest_jid()) msg['type'] = 'chat' @@ -149,19 +149,13 @@ class ConversationTab(OneToOneTab): self.text_win.refresh() self.input.refresh() - def command_xhtml(self, arg): - message = self.generate_xhtml_message(arg) - if message: - message.send() - self.core.add_message_to_text_buffer(self._text_buffer, message['body'], None, self.core.own_nick) - self.refresh() - - def command_last_activity(self, arg): + @command_args_parser.quoted(0, 1) + def command_last_activity(self, args): """ - /activity [jid] + /last_activity [jid] """ - if arg.strip(): - return self.core.command_last_activity(arg) + if args and args[0]: + return self.core.command_last_activity(args[0]) def callback(iq): if iq['type'] != 'result': @@ -188,10 +182,11 @@ class ConversationTab(OneToOneTab): self.add_message(msg) self.core.refresh_window() - self.core.xmpp.plugin['xep_0012'].get_last_activity(self.general_jid, callback=callback) + self.core.xmpp.plugin['xep_0012'].get_last_activity(self.get_dest_jid(), callback=callback) @refresh_wrapper.conditional - def command_info(self, arg): + @command_args_parser.ignored + def command_info(self): contact = roster[self.get_dest_jid()] jid = safeJID(self.get_dest_jid()) if contact: @@ -202,7 +197,7 @@ class ConversationTab(OneToOneTab): else: resource = None if resource: - status = (_('Status: %s') % resource.status) if resource.status else '' + status = ('Status: %s' % resource.status) if resource.status else '' self._text_buffer.add_message("\x19%(info_col)s}Show: %(show)s, %(status)s\x19o" % { 'show': resource.show or 'available', 'status': status, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}) return True @@ -210,23 +205,25 @@ class ConversationTab(OneToOneTab): self._text_buffer.add_message("\x19%(info_col)s}No information available\x19o" % {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}) return True - def command_unquery(self, arg): + @command_args_parser.ignored + def command_unquery(self): self.core.close_tab() - def command_version(self, arg): + @command_args_parser.quoted(0, 1) + def command_version(self, args): """ - /version + /version [jid] """ def callback(res): if not res: return self.core.information('Could not get the software version from %s' % (jid,), 'Warning') version = '%s is running %s version %s on %s' % (jid, - res.get('name') or _('an unknown software'), - res.get('version') or _('unknown'), - res.get('os') or _('an unknown platform')) + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') self.core.information(version, 'Info') - if arg: - return self.core.command_version(arg) + if args: + return self.core.command_version(args[0]) jid = safeJID(self.name) if not jid.resource: if jid in roster: @@ -381,7 +378,7 @@ class DynamicConversationTab(ConversationTab): self.info_header = windows.DynamicConversationInfoWin() ConversationTab.__init__(self, jid) self.register_command('unlock', self.unlock_command, - shortdesc=_('Unlock the conversation from a particular resource.')) + shortdesc='Unlock the conversation from a particular resource.') def lock(self, resource): """ @@ -393,8 +390,8 @@ class DynamicConversationTab(ConversationTab): info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT) jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) - message = _('%(info)sConversation locked to ' - '%(jid_c)s%(jid)s/%(resource)s%(info)s.') % { + message = ('%(info)sConversation locked to ' + '%(jid_c)s%(jid)s/%(resource)s%(info)s.') % { 'info': info, 'jid_c': jid_c, 'jid': self.name, @@ -418,14 +415,14 @@ class DynamicConversationTab(ConversationTab): jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID) if from_: - message = _('%(info)sConversation unlocked (received activity' - ' from %(jid_c)s%(jid)s%(info)s).') % { + message = ('%(info)sConversation unlocked (received activity' + ' from %(jid_c)s%(jid)s%(info)s).') % { 'info': info, 'jid_c': jid_c, 'jid': from_} self.add_message(message, typ=0) else: - message = _('%sConversation unlocked.') % info + message = '%sConversation unlocked.' % info self.add_message(message, typ=0) def get_dest_jid(self): diff --git a/src/tabs/listtab.py b/src/tabs/listtab.py index c5aab5eb..7021c8e3 100644 --- a/src/tabs/listtab.py +++ b/src/tabs/listtab.py @@ -4,8 +4,6 @@ sortable list. It should be inherited, to actually provide methods that insert items in the list, and that lets the user interact with them. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -52,7 +50,7 @@ class ListTab(Tab): self.key_func['KEY_RIGHT'] = self.list_header.sel_column_right self.key_func[' '] = self.sort_by self.register_command('close', self.close, - shortdesc=_('Close this tab.')) + shortdesc='Close this tab.') self.resize() self.update_keys() self.update_commands() @@ -121,7 +119,7 @@ class ListTab(Tab): """ If there's an error (retrieving the values etc) """ - self._error_message = _('Error: %(code)s - %(msg)s: %(body)s') % {'msg':msg, 'body':body, 'code':code} + self._error_message = 'Error: %(code)s - %(msg)s: %(body)s' % {'msg':msg, 'body':body, 'code':code} self.info_header.message = self._error_message self.info_header.refresh() curses.doupdate() diff --git a/src/tabs/muclisttab.py b/src/tabs/muclisttab.py index 55d5c2bd..c26fb268 100644 --- a/src/tabs/muclisttab.py +++ b/src/tabs/muclisttab.py @@ -4,8 +4,6 @@ A MucListTab is a tab listing the rooms on a conference server. It has no functionnality except scrolling the list, and allowing the user to join the rooms. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -22,9 +20,9 @@ class MucListTab(ListTab): plugin_keys = {} def __init__(self, server): - ListTab.__init__(self, server, + ListTab.__init__(self, server.full, "“j”: join room.", - _('Chatroom list on server %s (Loading)') % server, + 'Chatroom list on server %s (Loading)' % server, (('node-part', 0), ('name', 2), ('users', 3))) self.key_func['j'] = self.join_selected self.key_func['J'] = self.join_selected_no_focus @@ -56,7 +54,7 @@ class MucListTab(ListTab): item[0], item[2] or '', '') for item in get_items()] self.listview.set_lines(items) - self.info_header.message = _('Chatroom list on server %s') % self.name + self.info_header.message = 'Chatroom list on server %s' % self.name if self.core.current_tab() is self: self.refresh() else: diff --git a/src/tabs/muctab.py b/src/tabs/muctab.py index 8ac9b7e2..d4b13258 100644 --- a/src/tabs/muctab.py +++ b/src/tabs/muctab.py @@ -7,8 +7,6 @@ It keeps track of many things such as part/joins, maintains an user list, and updates private tabs when necessary. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -29,7 +27,7 @@ import windows import xhtml from common import safeJID from config import config -from decorators import refresh_wrapper +from decorators import refresh_wrapper, command_args_parser from logger import logger from roster import roster from theming import get_theme, dump_tuple @@ -37,11 +35,11 @@ from user import User SHOW_NAME = { - 'dnd': _('busy'), - 'away': _('away'), - 'xa': _('not available'), - 'chat': _('chatty'), - '': _('available') + 'dnd': 'busy', + 'away': 'away', + 'xa': 'not available', + 'chat': 'chatty', + '': 'available' } NS_MUC_USER = 'http://jabber.org/protocol/muc#user' @@ -55,13 +53,14 @@ class MucTab(ChatTab): message_type = 'groupchat' plugin_commands = {} plugin_keys = {} - def __init__(self, jid, nick): + def __init__(self, jid, nick, password=None): self.joined = False ChatTab.__init__(self, jid) if self.joined == False: self._state = 'disconnected' self.own_nick = nick self.name = jid + self.password = password self.users = [] self.privates = [] # private conversations self.topic = '' @@ -88,106 +87,112 @@ class MucTab(ChatTab): self.key_func['M-p'] = self.go_to_prev_hl # commands self.register_command('ignore', self.command_ignore, - usage=_('<nickname>'), - desc=_('Ignore a specified nickname.'), - shortdesc=_('Ignore someone'), + usage='<nickname>', + desc='Ignore a specified nickname.', + shortdesc='Ignore someone', completion=self.completion_ignore) self.register_command('unignore', self.command_unignore, - usage=_('<nickname>'), - desc=_('Remove the specified nickname from the ignore list.'), - shortdesc=_('Unignore someone.'), + usage='<nickname>', + desc='Remove the specified nickname from the ignore list.', + shortdesc='Unignore someone.', completion=self.completion_unignore) self.register_command('kick', self.command_kick, - usage=_('<nick> [reason]'), - desc=_('Kick the user with the specified nickname.' - ' You also can give an optional reason.'), - shortdesc=_('Kick someone.'), + usage='<nick> [reason]', + desc='Kick the user with the specified nickname.' + ' You also can give an optional reason.', + shortdesc='Kick someone.', completion=self.completion_quoted) self.register_command('ban', self.command_ban, - usage=_('<nick> [reason]'), - desc=_('Ban the user with the specified nickname.' - ' You also can give an optional reason.'), + usage='<nick> [reason]', + desc='Ban the user with the specified nickname.' + ' You also can give an optional reason.', shortdesc='Ban someone', completion=self.completion_quoted) self.register_command('role', self.command_role, - usage=_('<nick> <role> [reason]'), - desc=_('Set the role of an user. Roles can be:' - ' none, visitor, participant, moderator.' - ' You also can give an optional reason.'), - shortdesc=_('Set the role of an user.'), + usage='<nick> <role> [reason]', + desc='Set the role of an user. Roles can be:' + ' none, visitor, participant, moderator.' + ' You also can give an optional reason.', + shortdesc='Set the role of an user.', completion=self.completion_role) self.register_command('affiliation', self.command_affiliation, - usage=_('<nick or jid> <affiliation>'), - desc=_('Set the affiliation of an user. Affiliations can be:' - ' outcast, none, member, admin, owner.'), - shortdesc=_('Set the affiliation of an user.'), + usage='<nick or jid> <affiliation>', + desc='Set the affiliation of an user. Affiliations can be:' + ' outcast, none, member, admin, owner.', + shortdesc='Set the affiliation of an user.', completion=self.completion_affiliation) self.register_command('topic', self.command_topic, - usage=_('<subject>'), - desc=_('Change the subject of the room.'), - shortdesc=_('Change the subject.'), + usage='<subject>', + desc='Change the subject of the room.', + shortdesc='Change the subject.', completion=self.completion_topic) self.register_command('query', self.command_query, - usage=_('<nick> [message]'), - desc=_('Open a private conversation with <nick>. This nick' - ' has to be present in the room you\'re currently in.' - ' If you specified a message after the nickname, it ' - 'will immediately be sent to this user.'), - shortdesc=_('Query an user.'), + usage='<nick> [message]', + desc='Open a private conversation with <nick>. This nick' + ' has to be present in the room you\'re currently in.' + ' If you specified a message after the nickname, it ' + 'will immediately be sent to this user.', + shortdesc='Query an user.', completion=self.completion_quoted) self.register_command('part', self.command_part, - usage=_('[message]'), - desc=_('Disconnect from a room. You can' - ' specify an optional message.'), - shortdesc=_('Leave the room.')) + usage='[message]', + desc='Disconnect from a room. You can' + ' specify an optional message.', + shortdesc='Leave the room.') self.register_command('close', self.command_close, - usage=_('[message]'), - desc=_('Disconnect from a room and close the tab.' - ' You can specify an optional message if ' - 'you are still connected.'), - shortdesc=_('Close the tab.')) + usage='[message]', + desc='Disconnect from a room and close the tab.' + ' You can specify an optional message if ' + 'you are still connected.', + shortdesc='Close the tab.') self.register_command('nick', self.command_nick, - usage=_('<nickname>'), - desc=_('Change your nickname in the current room.'), - shortdesc=_('Change your nickname.'), + usage='<nickname>', + desc='Change your nickname in the current room.', + shortdesc='Change your nickname.', completion=self.completion_nick) self.register_command('recolor', self.command_recolor, - usage=_('[random]'), - desc=_('Re-assign a color to all participants of the' - ' current room, based on the last time they talked.' - ' Use this if the participants currently talking ' - 'have too many identical colors. Use /recolor random' - ' for a non-deterministic result.'), - shortdesc=_('Change the nicks colors.'), + usage='[random]', + desc='Re-assign a color to all participants of the' + ' current room, based on the last time they talked.' + ' Use this if the participants currently talking ' + 'have too many identical colors. Use /recolor random' + ' for a non-deterministic result.', + shortdesc='Change the nicks colors.', completion=self.completion_recolor) + self.register_command('color', self.command_color, + usage='<nick> <color>', + desc='Fix a color for a nick. Use "unset" instead of a color' + ' to remove the attribution', + shortdesc='Fix a color for a nick.', + completion=self.completion_color) self.register_command('cycle', self.command_cycle, - usage=_('[message]'), - desc=_('Leave the current room and rejoin it immediately.'), - shortdesc=_('Leave and re-join the room.')) + usage='[message]', + desc='Leave the current room and rejoin it immediately.', + shortdesc='Leave and re-join the room.') self.register_command('info', self.command_info, - usage=_('<nickname>'), - desc=_('Display some information about the user ' - 'in the MUC: its/his/her role, affiliation,' - ' status and status message.'), - shortdesc=_('Show an user\'s infos.'), + usage='<nickname>', + desc='Display some information about the user ' + 'in the MUC: its/his/her role, affiliation,' + ' status and status message.', + shortdesc='Show an user\'s infos.', completion=self.completion_info) self.register_command('configure', self.command_configure, - desc=_('Configure the current room, through a form.'), - shortdesc=_('Configure the room.')) + desc='Configure the current room, through a form.', + shortdesc='Configure the room.') self.register_command('version', self.command_version, - usage=_('<jid or nick>'), - desc=_('Get the software version of the given JID' - ' or nick in room (usually its XMPP client' - ' and Operating System).'), - shortdesc=_('Get the software version of a jid.'), + usage='<jid or nick>', + desc='Get the software version of the given JID' + ' or nick in room (usually its XMPP client' + ' and Operating System).', + shortdesc='Get the software version of a jid.', completion=self.completion_version) self.register_command('names', self.command_names, - desc=_('Get the users in the room with their roles.'), - shortdesc=_('List the users.')) + desc='Get the users in the room with their roles.', + shortdesc='List the users.') self.register_command('invite', self.command_invite, - desc=_('Invite a contact to this room'), - usage=_('<jid> [reason]'), - shortdesc=_('Invite a contact to this room'), + desc='Invite a contact to this room', + usage='<jid> [reason]', + shortdesc='Invite a contact to this room', completion=self.completion_invite) if self.core.xmpp.boundjid.server == "gmail.com": #gmail sucks @@ -263,6 +268,21 @@ class MucTab(ChatTab): return the_input.new_completion(['random'], 1, '', quotify=False) return True + def completion_color(self, the_input): + """Completion for /color""" + n = the_input.get_argument_position(quoted=True) + if n == 1: + userlist = [user.nick for user in self.users] + if self.own_nick in userlist: + userlist.remove(self.own_nick) + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: + colors = [i for i in xhtml.colors if i] + colors.sort() + colors.append('unset') + colors.append('random') + return the_input.new_completion(colors, 2, '', quotify=False) + def completion_ignore(self, the_input): """Completion for /ignore""" userlist = [user.nick for user in self.users] @@ -302,15 +322,12 @@ class MucTab(ChatTab): return the_input.new_completion(possible_affiliations, 2, '', quotify=True) + @command_args_parser.quoted(1, 1, ['']) def command_invite(self, args): """/invite <jid> [reason]""" - args = common.shell_split(args) - if len(args) == 1: - jid, reason = args[0], '' - elif len(args) == 2: - jid, reason = args - else: + if args is None: return self.core.command_help('invite') + jid, reason = args self.core.command_invite('%s %s "%s"' % (jid, self.name, reason)) def completion_invite(self, the_input): @@ -329,15 +346,17 @@ class MucTab(ChatTab): self.user_win.refresh(self.users) self.input.refresh() - def command_info(self, arg): + @command_args_parser.quoted(1) + def command_info(self, args): """ /info <nick> """ - if not arg: + if args is None: return self.core.command_help('info') - user = self.get_user_by_name(arg) + nick = args[0] + user = self.get_user_by_name(nick) if not user: - return self.core.information(_("Unknown user: %s") % arg) + return self.core.information("Unknown user: %s" % nick) theme = get_theme() if user.jid: user_jid = ' (\x19%s}%s\x19o)' % ( @@ -345,10 +364,10 @@ class MucTab(ChatTab): user.jid) else: user_jid = '' - info = _('\x19%s}%s\x19o%s: show: \x19%s}%s\x19o, affiliation:' - ' \x19%s}%s\x19o, role: \x19%s}%s\x19o%s') % ( + info = ('\x19%s}%s\x19o%s: show: \x19%s}%s\x19o, affiliation:' + ' \x19%s}%s\x19o, role: \x19%s}%s\x19o%s') % ( dump_tuple(user.color), - arg, + nick, user_jid, dump_tuple(theme.color_show(user.show)), user.show or 'Available', @@ -360,19 +379,20 @@ class MucTab(ChatTab): self.add_message(info, typ=0) self.core.refresh_window() - def command_configure(self, arg): + @command_args_parser.quoted(0) + def command_configure(self, ignored): """ /configure """ def on_form_received(form): if not form: self.core.information( - _('Could not retrieve the configuration form'), - _('Error')) + 'Could not retrieve the configuration form', + 'Error') return self.core.open_new_form(form, self.cancel_config, self.send_config) - form = fixes.get_room_form(self.core.xmpp, self.name, on_form_received) + fixes.get_room_form(self.core.xmpp, self.name, on_form_received) def cancel_config(self, form): """ @@ -388,30 +408,53 @@ class MucTab(ChatTab): muc.configure_room(self.core.xmpp, self.name, form) self.core.close_tab() - def command_cycle(self, arg): + @command_args_parser.raw + def command_cycle(self, msg): """/cycle [reason]""" - self.command_part(arg) + self.command_part(msg) self.disconnect() self.user_win.pos = 0 self.core.disable_private_tabs(self.name) self.core.command_join('"/%s"' % self.own_nick) - def command_recolor(self, arg): + @command_args_parser.quoted(0, 1, ['']) + def command_recolor(self, args): """ /recolor [random] Re-assign color to the participants of the room """ - arg = arg.strip() + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + if deterministic: + for user in self.users: + if user.nick == self.own_nick: + continue + color = self.search_for_color(user.nick) + if color != '': + continue + user.set_deterministic_color() + if args[0] == 'random': + self.core.information('"random" was provided, but poezio is ' + 'configured to use deterministic colors', + 'Warning') + self.user_win.refresh(self.users) + self.input.refresh() + return compare_users = lambda x: x.last_talked users = list(self.users) sorted_users = sorted(users, key=compare_users, reverse=True) + full_sorted_users = sorted_users[:] # search our own user, to remove it from the list - for user in sorted_users: + # Also remove users whose color is fixed + for user in full_sorted_users: + color = self.search_for_color(user.nick) if user.nick == self.own_nick: sorted_users.remove(user) user.color = get_theme().COLOR_OWN_NICK + elif color != '': + sorted_users.remove(user) + user.change_color(color, deterministic) colors = list(get_theme().LIST_COLOR_NICKNAMES) - if arg and arg == 'random': + if args[0] == 'random': random.shuffle(colors) for i, user in enumerate(sorted_users): user.color = colors[i % len(colors)] @@ -420,41 +463,86 @@ class MucTab(ChatTab): self.text_win.refresh() self.input.refresh() - def command_version(self, arg): + @command_args_parser.quoted(2, 2, ['']) + def command_color(self, args): + """ + /color <nick> <color> + Fix a color for a nick. + Use "unset" instead of a color to remove the attribution. + User "random" to attribute a random color. + """ + if args is None: + return self.core.command_help('color') + nick = args[0] + color = args[1].lower() + user = self.get_user_by_name(nick) + if not color in xhtml.colors and color not in ('unset', 'random'): + return self.core.information("Unknown color: %s" % color, 'Error') + if user and user.nick == self.own_nick: + return self.core.information("You cannot change the color of your" + " own nick.", 'Error') + if color == 'unset': + if config.remove_and_save(nick, 'muc_colors'): + self.core.information('Color for nick %s unset' % (nick)) + else: + if color == 'random': + color = random.choice(list(xhtml.colors)) + if user: + user.change_color(color) + config.set_and_save(nick, color, 'muc_colors') + nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name) + if nick_color_aliases: + # if any user in the room has a nick which is an alias of the + # nick, update its color + for tab in self.core.get_tabs(MucTab): + for u in tab.users: + nick_alias = re.sub('^_*', '', u.nick) + nick_alias = re.sub('_*$', '', nick_alias) + if nick_alias == nick: + u.change_color(color) + self.text_win.rebuild_everything(self._text_buffer) + self.user_win.refresh(self.users) + self.text_win.refresh() + self.input.refresh() + + @command_args_parser.quoted(1) + def command_version(self, args): """ /version <jid or nick> """ def callback(res): if not res: - return self.core.information(_('Could not get the software ' - 'version from %s') % (jid,), - _('Warning')) - version = _('%s is running %s version %s on %s') % ( + return self.core.information('Could not get the software ' + 'version from %s' % (jid,), + 'Warning') + version = '%s is running %s version %s on %s' % ( jid, - res.get('name') or _('an unknown software'), - res.get('version') or _('unknown'), - res.get('os') or _('an unknown platform')) + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') self.core.information(version, 'Info') - if not arg: + if args is None: return self.core.command_help('version') - if arg in [user.nick for user in self.users]: + nick = args[0] + if nick in [user.nick for user in self.users]: jid = safeJID(self.name).bare - jid = safeJID(jid + '/' + arg) + jid = safeJID(jid + '/' + nick) else: - jid = safeJID(arg) + jid = safeJID(nick) fixes.get_version(self.core.xmpp, jid, - callback=callback) + callback=callback) - def command_nick(self, arg): + @command_args_parser.quoted(1) + def command_nick(self, args): """ /nick <nickname> """ - if not arg: + if args is None: return self.core.command_help('nick') - nick = arg + nick = args[0] if not self.joined: - return self.core.information(_('/nick only works in joined rooms'), - _('Info')) + return self.core.information('/nick only works in joined rooms', + 'Info') current_status = self.core.get_status() if not safeJID(self.name + '/' + nick): return self.core.information('Invalid nick', 'Info') @@ -462,11 +550,12 @@ class MucTab(ChatTab): current_status.message, current_status.show) - def command_part(self, arg): + @command_args_parser.quoted(0, 1, ['']) + def command_part(self, args): """ /part [msg] """ - arg = arg.strip() + arg = args[0] msg = None if self.joined: info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) @@ -480,24 +569,24 @@ class MucTab(ChatTab): color = 3 if arg: - msg = _('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' - 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' - ' left the chatroom' - ' (\x19o%(reason)s\x19%(info_col)s})') % { - 'info_col': info_col, 'reason': arg, - 'spec': char_quit, 'color': color, - 'color_spec': spec_col, - 'nick': self.own_nick, - } + msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' + 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' + ' left the chatroom' + ' (\x19o%(reason)s\x19%(info_col)s})') % { + 'info_col': info_col, 'reason': arg, + 'spec': char_quit, 'color': color, + 'color_spec': spec_col, + 'nick': self.own_nick, + } else: - msg = _('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' - 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' - ' left the chatroom') % { - 'info_col': info_col, - 'spec': char_quit, 'color': color, - 'color_spec': spec_col, - 'nick': self.own_nick, - } + msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} ' + 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})' + ' left the chatroom') % { + 'info_col': info_col, + 'spec': char_quit, 'color': color, + 'color_spec': spec_col, + 'nick': self.own_nick, + } self.add_message(msg, typ=2) self.disconnect() @@ -507,49 +596,52 @@ class MucTab(ChatTab): self.refresh() self.core.doupdate() - def command_close(self, arg): + @command_args_parser.raw + def command_close(self, msg): """ /close [msg] """ - self.command_part(arg) + self.command_part(msg) self.core.close_tab() - def command_query(self, arg): + @command_args_parser.quoted(1, 1) + def command_query(self, args): """ /query <nick> [message] """ - args = common.shell_split(arg) - if len(args) < 1: - return + if args is None: + return self.core.command_help('query') nick = args[0] r = None for user in self.users: if user.nick == nick: r = self.core.open_private_window(self.name, user.nick) - if r and len(args) > 1: + if r and len(args) == 2: msg = args[1] self.core.current_tab().command_say( xhtml.convert_simple_to_full_colors(msg)) if not r: - self.core.information(_("Cannot find user: %s" % nick), 'Error') + self.core.information("Cannot find user: %s" % nick, 'Error') - def command_topic(self, arg): + @command_args_parser.raw + def command_topic(self, subject): """ /topic [new topic] """ - if not arg.strip(): + if not subject: self._text_buffer.add_message( - _("\x19%s}The subject of the room is: %s %s") % + "\x19%s}The subject of the room is: %s %s" % (dump_tuple(get_theme().COLOR_INFORMATION_TEXT), self.topic, '(set by %s)' % self.topic_from if self.topic_from else '')) self.refresh() return - subject = arg + muc.change_subject(self.core.xmpp, self.name, subject) - def command_names(self, arg=None): + @command_args_parser.quoted(0) + def command_names(self, args): """ /names """ @@ -620,29 +712,28 @@ class MucTab(ChatTab): return the_input.new_completion(word_list, 1, quotify=True) - def command_kick(self, arg): + @command_args_parser.quoted(1, 1) + def command_kick(self, args): """ /kick <nick> [reason] """ - args = common.shell_split(arg) - if not args: - self.core.command_help('kick') + if args is None: + return self.core.command_help('kick') + if len(args) == 2: + msg = ' "%s"' % args[1] else: - if len(args) > 1: - msg = ' "%s"' % args[1] - else: - msg = '' - self.command_role('"'+args[0]+ '" none'+msg) + msg = '' + self.command_role('"'+args[0]+ '" none'+msg) - def command_ban(self, arg): + @command_args_parser.quoted(1, 1) + def command_ban(self, args): """ /ban <nick> [reason] """ def callback(iq): if iq['type'] == 'error': self.core.room_error(iq, self.name) - args = common.shell_split(arg) - if not args: + if args is None: return self.core.command_help('ban') if len(args) > 1: msg = args[1] @@ -661,7 +752,8 @@ class MucTab(ChatTab): if not res: self.core.information('Could not ban user', 'Error') - def command_role(self, arg): + @command_args_parser.quoted(2, 1, ['']) + def command_role(self, args): """ /role <nick> <role> [reason] Changes the role of an user @@ -670,24 +762,25 @@ class MucTab(ChatTab): def callback(iq): if iq['type'] == 'error': self.core.room_error(iq, self.name) - args = common.shell_split(arg) - if len(args) < 2: - self.core.command_help('role') - return - nick, role = args[0], args[1] - if len(args) > 2: - reason = ' '.join(args[2:]) - else: - reason = '' - if not self.joined or \ - not role in ('none', 'visitor', 'participant', 'moderator'): - return + + if args is None: + return self.core.command_help('role') + + nick, role, reason = args[0], args[1].lower(), args[2] + + valid_roles = ('none', 'visitor', 'participant', 'moderator') + + if not self.joined or role not in valid_roles: + return self.core.information('The role must be one of ' + ', '.join(valid_roles), + 'Error') + if not safeJID(self.name + '/' + nick): - return self.core('Invalid nick', 'Info') + return self.core.information('Invalid nick', 'Info') muc.set_user_role(self.core.xmpp, self.name, nick, reason, role, callback=callback) - def command_affiliation(self, arg): + @command_args_parser.quoted(2) + def command_affiliation(self, args): """ /affiliation <nick> <role> Changes the affiliation of an user @@ -696,16 +789,20 @@ class MucTab(ChatTab): def callback(iq): if iq['type'] == 'error': self.core.room_error(iq, self.name) - args = common.shell_split(arg) - if len(args) < 2: - self.core.command_help('affiliation') - return + + if args is None: + return self.core.command_help('affiliation') + nick, affiliation = args[0], args[1].lower() + if not self.joined: return - if affiliation not in ('outcast', 'none', 'member', 'admin', 'owner'): - self.core.command_help('affiliation') - return + + valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner') + if affiliation not in valid_affiliations: + return self.core.information('The affiliation must be one of ' + ', '.join(valid_affiliations), + 'Error') + if nick in [user.nick for user in self.users]: res = muc.set_user_affiliation(self.core.xmpp, self.name, affiliation, nick=nick, @@ -715,8 +812,9 @@ class MucTab(ChatTab): affiliation, jid=safeJID(nick), callback=callback) if not res: - self.core.information(_('Could not set affiliation'), _('Error')) + self.core.information('Could not set affiliation', 'Error') + @command_args_parser.raw def command_say(self, line, correct=False): """ /say <message> @@ -755,45 +853,48 @@ class MucTab(ChatTab): msg.send() self.chat_state = needed - def command_xhtml(self, arg): - message = self.generate_xhtml_message(arg) + @command_args_parser.raw + def command_xhtml(self, msg): + message = self.generate_xhtml_message(msg) if message: message['type'] = 'groupchat' message.send() - def command_ignore(self, arg): + @command_args_parser.quoted(1) + def command_ignore(self, args): """ /ignore <nick> """ - if not arg: - self.core.command_help('ignore') - return - nick = arg + if args is None: + return self.core.command_help('ignore') + + nick = args[0] user = self.get_user_by_name(nick) if not user: - self.core.information(_('%s is not in the room') % nick) + self.core.information('%s is not in the room' % nick) elif user in self.ignores: - self.core.information(_('%s is already ignored') % nick) + self.core.information('%s is already ignored' % nick) else: self.ignores.append(user) - self.core.information(_("%s is now ignored") % nick, 'info') + self.core.information("%s is now ignored" % nick, 'info') - def command_unignore(self, arg): + @command_args_parser.quoted(1) + def command_unignore(self, args): """ /unignore <nick> """ - if not arg: - self.core.command_help('unignore') - return - nick = arg + if args is None: + return self.core.command_help('unignore') + + nick = args[0] user = self.get_user_by_name(nick) if not user: - self.core.information(_('%s is not in the room') % nick) + self.core.information('%s is not in the room' % nick) elif user not in self.ignores: - self.core.information(_('%s is not ignored') % nick) + self.core.information('%s is not ignored' % nick) else: self.ignores.remove(user) - self.core.information(_('%s is now unignored') % nick) + self.core.information('%s is now unignored' % nick) def completion_unignore(self, the_input): if the_input.get_argument_position() == 1: @@ -980,12 +1081,14 @@ class MucTab(ChatTab): role = presence['muc']['role'] jid = presence['muc']['jid'] typ = presence['type'] + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) + color = self.search_for_color(from_nick) if not self.joined: # user in the room BEFORE us. # ignore redondant presence message, see bug #1509 if (from_nick not in [user.nick for user in self.users] and typ != "unavailable"): new_user = User(from_nick, affiliation, show, - status, role, jid) + status, role, jid, deterministic, color) self.users.append(new_user) self.core.events.trigger('muc_join', presence, self) if '110' in status_codes or self.own_nick == from_nick: @@ -1015,9 +1118,9 @@ class MucTab(ChatTab): spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR) self.add_message( - _('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You ' - '(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined' - ' the chatroom') % + '\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You ' + '(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined' + ' the chatroom' % { 'nick': from_nick, 'spec': get_theme().CHAR_JOIN, @@ -1028,21 +1131,21 @@ class MucTab(ChatTab): typ=2) if '201' in status_codes: self.add_message( - _('\x19%(info_col)s}Info: The room ' - 'has been created') % + '\x19%(info_col)s}Info: The room ' + 'has been created' % {'info_col': info_col}, typ=0) if '170' in status_codes: self.add_message( - _('\x19%(warn_col)s}Warning:\x19%(info_col)s}' - ' This room is publicly logged') % + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is publicly logged' % {'info_col': info_col, 'warn_col': warn_col}, typ=0) if '100' in status_codes: self.add_message( - _('\x19%(warn_col)s}Warning:\x19%(info_col)s}' - ' This room is not anonymous.') % + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is not anonymous.' % {'info_col': info_col, 'warn_col': warn_col}, typ=0) @@ -1065,7 +1168,7 @@ class MucTab(ChatTab): if not user: self.core.events.trigger('muc_join', presence, self) self.on_user_join(from_nick, affiliation, show, status, role, - jid) + jid, color) # nick change elif change_nick: self.core.events.trigger('muc_nickchange', presence, self) @@ -1105,8 +1208,8 @@ class MucTab(ChatTab): def on_non_member_kicked(self): """We have been kicked because the MUC is members-only""" self.add_message( - _('\x19%(info_col)s}You have been kicked because you ' - 'are not a member and the room is now members-only.') % { + '\x19%(info_col)s}You have been kicked because you ' + 'are not a member and the room is now members-only.' % { 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) self.disconnect() @@ -1114,18 +1217,19 @@ class MucTab(ChatTab): def on_muc_shutdown(self): """We have been kicked because the MUC service is shutting down""" self.add_message( - _('\x19%(info_col)s}You have been kicked because the' - ' MUC service is shutting down.') % { + '\x19%(info_col)s}You have been kicked because the' + ' MUC service is shutting down.' % { 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) self.disconnect() - def on_user_join(self, from_nick, affiliation, show, status, role, jid): + def on_user_join(self, from_nick, affiliation, show, status, role, jid, color): """ When a new user joins the groupchat """ + deterministic = config.get_by_tabname('deterministic_nick_colors', self.name) user = User(from_nick, affiliation, - show, status, role, jid) + show, status, role, jid, deterministic, color) self.users.append(user) hide_exit_join = config.get_by_tabname('hide_exit_join', self.general_jid) @@ -1139,17 +1243,17 @@ class MucTab(ChatTab): spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR) char_join = get_theme().CHAR_JOIN if not jid.full: - msg = _('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s' - '\x19%(info_col)s} joined the chatroom') % { + msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} joined the chatroom') % { 'nick': from_nick, 'spec': char_join, 'color': color, 'info_col': info_col, 'color_spec': spec_col, } else: - msg = _('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s ' - '\x19%(info_col)s}(\x19%(jid_color)s}%(jid)s\x19' - '%(info_col)s}) joined the chatroom') % { + msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s ' + '\x19%(info_col)s}(\x19%(jid_color)s}%(jid)s\x19' + '%(info_col)s}) joined the chatroom') % { 'spec': char_join, 'nick': from_nick, 'color':color, 'jid':jid.full, 'info_col': info_col, @@ -1166,6 +1270,12 @@ class MucTab(ChatTab): self.own_nick = new_nick # also change our nick in all private discussions of this room self.core.on_muc_own_nickchange(self) + else: + color = config.get_by_tabname(new_nick, 'muc_colors') + if color != '': + deterministic = config.get_by_tabname('deterministic_nick_colors', + self.name) + user.change_color(color, deterministic) user.change_nick(new_nick) if config.get_by_tabname('display_user_color_in_join_part', @@ -1174,8 +1284,8 @@ class MucTab(ChatTab): else: color = 3 info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - self.add_message(_('\x19%(color)s}%(old)s\x19%(info_col)s} is' - ' now known as \x19%(color)s}%(new)s') % { + self.add_message('\x19%(color)s}%(old)s\x19%(info_col)s} is' + ' now known as \x19%(color)s}%(new)s' % { 'old':from_nick, 'new':new_nick, 'color':color, 'info_col': info_col}, typ=2) @@ -1198,13 +1308,13 @@ class MucTab(ChatTab): if from_nick == self.own_nick: # we are banned if by: - kick_msg = _('\x191}%(spec)s \x193}You\x19%(info_col)s}' - ' have been banned by \x194}%(by)s') % { + kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}' + ' have been banned by \x194}%(by)s') % { 'spec': char_kick, 'by': by, 'info_col': info_col} else: - kick_msg = _('\x191}%(spec)s \x193}You\x19' - '%(info_col)s} have been banned.') % { + kick_msg = ('\x191}%(spec)s \x193}You\x19' + '%(info_col)s} have been banned.') % { 'spec': char_kick, 'info_col': info_col} self.core.disable_private_tabs(self.name, reason=kick_msg) self.disconnect() @@ -1233,20 +1343,20 @@ class MucTab(ChatTab): color = 3 if by: - kick_msg = _('\x191}%(spec)s \x19%(color)s}' - '%(nick)s\x19%(info_col)s} ' - 'has been banned by \x194}%(by)s') % { + kick_msg = ('\x191}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} ' + 'has been banned by \x194}%(by)s') % { 'spec': char_kick, 'nick': from_nick, 'color': color, 'by': by, 'info_col': info_col} else: - kick_msg = _('\x191}%(spec)s \x19%(color)s}%(nick)s' - '\x19%(info_col)s} has been banned') % { + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been banned') % { 'spec': char_kick, 'nick': from_nick, 'color': color, 'info_col': info_col} if reason is not None and reason.text: - kick_msg += _('\x19%(info_col)s} Reason: \x196}' - '%(reason)s\x19%(info_col)s}') % { + kick_msg += ('\x19%(info_col)s} Reason: \x196}' + '%(reason)s\x19%(info_col)s}') % { 'reason': reason.text, 'info_col': info_col} self.add_message(kick_msg, typ=2) @@ -1266,14 +1376,14 @@ class MucTab(ChatTab): by = actor_elem.get('nick') or actor_elem.get('jid') if from_nick == self.own_nick: # we are kicked if by: - kick_msg = _('\x191}%(spec)s \x193}You\x19' - '%(info_col)s} have been kicked' - ' by \x193}%(by)s') % { + kick_msg = ('\x191}%(spec)s \x193}You\x19' + '%(info_col)s} have been kicked' + ' by \x193}%(by)s') % { 'spec': char_kick, 'by': by, 'info_col': info_col} else: - kick_msg = _('\x191}%(spec)s \x193}You\x19%(info_col)s}' - ' have been kicked.') % { + kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}' + ' have been kicked.') % { 'spec': char_kick, 'info_col': info_col} self.core.disable_private_tabs(self.name, reason=kick_msg) @@ -1302,19 +1412,19 @@ class MucTab(ChatTab): else: color = 3 if by: - kick_msg = _('\x191}%(spec)s \x19%(color)s}%(nick)s' - '\x19%(info_col)s} has been kicked by ' - '\x193}%(by)s') % { + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been kicked by ' + '\x193}%(by)s') % { 'spec': char_kick, 'nick':from_nick, 'color':color, 'by':by, 'info_col': info_col} else: - kick_msg = _('\x191}%(spec)s \x19%(color)s}%(nick)s' - '\x19%(info_col)s} has been kicked') % { + kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s' + '\x19%(info_col)s} has been kicked') % { 'spec': char_kick, 'nick': from_nick, 'color':color, 'info_col': info_col} if reason is not None and reason.text: - kick_msg += _('\x19%(info_col)s} Reason: \x196}' - '%(reason)s') % { + kick_msg += ('\x19%(info_col)s} Reason: \x196}' + '%(reason)s') % { 'reason': reason.text, 'info_col': info_col} self.add_message(kick_msg, typ=2) @@ -1343,19 +1453,19 @@ class MucTab(ChatTab): spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR) if not jid.full: - leave_msg = _('\x19%(color_spec)s}%(spec)s \x19%(color)s}' - '%(nick)s\x19%(info_col)s} has left the ' - 'chatroom') % { + leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} has left the ' + 'chatroom') % { 'nick':from_nick, 'color':color, 'spec':get_theme().CHAR_QUIT, 'info_col': info_col, 'color_spec': spec_col} else: jid_col = dump_tuple(get_theme().COLOR_MUC_JID) - leave_msg = _('\x19%(color_spec)s}%(spec)s \x19%(color)s}' - '%(nick)s\x19%(info_col)s} (\x19%(jid_col)s}' - '%(jid)s\x19%(info_col)s}) has left the ' - 'chatroom') % { + leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}' + '%(nick)s\x19%(info_col)s} (\x19%(jid_col)s}' + '%(jid)s\x19%(info_col)s}) has left the ' + 'chatroom') % { 'spec':get_theme().CHAR_QUIT, 'nick':from_nick, 'color':color, 'jid':jid.full, 'info_col': info_col, @@ -1381,33 +1491,29 @@ class MucTab(ChatTab): else: color = 3 if from_nick == self.own_nick: - msg = _('\x19%(color)s}You\x19%(info_col)s} changed: ') % { - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), - 'color': color} + msg = '\x19%(color)s}You\x19%(info_col)s} changed: ' % { + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), + 'color': color} else: - msg = _('\x19%(color)s}%(nick)s\x19%(info_col)s} changed: ') % { - 'nick': from_nick, 'color': color, - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)} - if show not in SHOW_NAME: - self.core.information(_("%s from room %s sent an invalid show: %s") - % (from_nick, from_room, show), - _("Warning")) + msg = '\x19%(color)s}%(nick)s\x19%(info_col)s} changed: ' % { + 'nick': from_nick, 'color': color, + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)} if affiliation != user.affiliation: - msg += _('affiliation: %s, ') % affiliation + msg += 'affiliation: %s, ' % affiliation display_message = True if role != user.role: - msg += _('role: %s, ') % role + msg += 'role: %s, ' % role display_message = True if show != user.show and show in SHOW_NAME: - msg += _('show: %s, ') % SHOW_NAME[show] + msg += 'show: %s, ' % SHOW_NAME[show] display_message = True if status != user.status: # if the user sets his status to nothing if status: - msg += _('status: %s, ') % status + msg += 'status: %s, ' % status display_message = True elif show in SHOW_NAME and show == user.show: - msg += _('show: %s, ') % SHOW_NAME[show] + msg += 'show: %s, ' % SHOW_NAME[show] display_message = True if not display_message: return @@ -1461,8 +1567,8 @@ class MucTab(ChatTab): """ if time is None and self.joined: # don't log the history messages if not logger.log_message(self.name, nickname, txt, typ=typ): - self.core.information(_('Unable to write in the log file'), - _('Error')) + self.core.information('Unable to write in the log file', + 'Error') def do_highlight(self, txt, time, nickname): """ @@ -1581,6 +1687,22 @@ class MucTab(ChatTab): else: # Re-send a self-ping in a few seconds self.enable_self_ping_event() + def search_for_color(self, nick): + """ + Search for the color of a nick in the config file. + Also, look at the colors of its possible aliases if nick_color_aliases + is set. + """ + color = config.get_by_tabname(nick, 'muc_colors') + if color != '': + return color + nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name) + if nick_color_aliases: + nick_alias = re.sub('^_*', '', nick) + nick_alias = re.sub('_*$', '', nick_alias) + color = config.get_by_tabname(nick_alias, 'muc_colors') + return color + def on_self_ping_failed(self, iq): self.command_part("the MUC server is not responding") self.core.refresh_window() diff --git a/src/tabs/privatetab.py b/src/tabs/privatetab.py index 4c01cd70..a715a922 100644 --- a/src/tabs/privatetab.py +++ b/src/tabs/privatetab.py @@ -10,8 +10,6 @@ both participant’s nicks. It also has slightly different features than the ConversationTab (such as tab-completion on nicks from the room). """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) @@ -27,6 +25,7 @@ from config import config from decorators import refresh_wrapper from logger import logger from theming import get_theme, dump_tuple +from decorators import command_args_parser class PrivateTab(OneToOneTab): """ @@ -48,15 +47,15 @@ class PrivateTab(OneToOneTab): self.key_func['^I'] = self.completion # commands self.register_command('info', self.command_info, - desc=_('Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.'), - shortdesc=_('Info about the user.')) + desc='Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.', + shortdesc='Info about the user.') self.register_command('unquery', self.command_unquery, - shortdesc=_('Close the tab.')) + shortdesc='Close the tab.') self.register_command('close', self.command_unquery, - shortdesc=_('Close the tab.')) + shortdesc='Close the tab.') self.register_command('version', self.command_version, - desc=_('Get the software version of the current interlocutor (usually its XMPP client and Operating System).'), - shortdesc=_('Get the software version of a jid.')) + desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).', + shortdesc='Get the software version of a jid.') self.resize() self.parent_muc = self.core.get_tab_by_name(safeJID(name).bare, MucTab) self.on = True @@ -87,13 +86,14 @@ class PrivateTab(OneToOneTab): def load_logs(self, log_nb): logs = logger.get_logs(safeJID(self.name).full.replace('/', '\\'), log_nb) + return logs def log_message(self, txt, nickname, time=None, typ=1): """ Log the messages in the archives. """ if not logger.log_message(self.name, nickname, txt, date=time, typ=typ): - self.core.information(_('Unable to write in the log file'), 'Error') + self.core.information('Unable to write in the log file', 'Error') def on_close(self): self.parent_muc.privates.remove(self) @@ -120,6 +120,7 @@ class PrivateTab(OneToOneTab): empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//')) self.send_composing_chat_state(empty_after) + @command_args_parser.raw def command_say(self, line, attention=False, correct=False): if not self.on: return @@ -182,13 +183,15 @@ class PrivateTab(OneToOneTab): self.text_win.refresh() self.input.refresh() - def command_unquery(self, arg): + @command_args_parser.ignored + def command_unquery(self): """ /unquery """ self.core.close_tab() - def command_version(self, arg): + @command_args_parser.quoted(0, 1) + def command_version(self, args): """ /version """ @@ -196,22 +199,23 @@ class PrivateTab(OneToOneTab): if not res: return self.core.information('Could not get the software version from %s' % (jid,), 'Warning') version = '%s is running %s version %s on %s' % (jid, - res.get('name') or _('an unknown software'), - res.get('version') or _('unknown'), - res.get('os') or _('an unknown platform')) + res.get('name') or 'an unknown software', + res.get('version') or 'unknown', + res.get('os') or 'an unknown platform') self.core.information(version, 'Info') - if arg: - return self.core.command_version(arg) + if args: + return self.core.command_version(args[0]) jid = safeJID(self.name) fixes.get_version(self.core.xmpp, jid, callback=callback) + @command_args_parser.quoted(0, 1) def command_info(self, arg): """ /info """ - if arg: - self.parent_muc.command_info(arg) + if arg and arg[0]: + self.parent_muc.command_info(arg[0]) else: user = safeJID(self.name).resource self.parent_muc.command_info(user) @@ -319,9 +323,9 @@ class PrivateTab(OneToOneTab): """ self.deactivate() if not status_message: - self.add_message(_('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room') % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) else: - self.add_message(_('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room (%(status)s)"') % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'status': status_message, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) + self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room (%(status)s)"' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'status': status_message, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2) return self.core.current_tab() is self @refresh_wrapper.conditional diff --git a/src/tabs/rostertab.py b/src/tabs/rostertab.py index 878e89ed..aaff7de3 100644 --- a/src/tabs/rostertab.py +++ b/src/tabs/rostertab.py @@ -5,15 +5,16 @@ rectangle shows the current contact info. This module also includes functions to match users in the roster. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) +import base64 import curses import difflib import os +import ssl from os import getenv, path +from functools import partial from . import Tab @@ -25,6 +26,7 @@ from contact import Contact, Resource from decorators import refresh_wrapper from roster import RosterGroup, roster from theming import get_theme, dump_tuple +from decorators import command_args_parser class RosterInfoTab(Tab): """ @@ -44,107 +46,315 @@ class RosterInfoTab(Tab): self.input = self.default_help_message self.state = 'normal' self.key_func['^I'] = self.completion - self.key_func[' '] = self.on_space self.key_func["/"] = self.on_slash - self.key_func["KEY_UP"] = self.move_cursor_up - self.key_func["KEY_DOWN"] = self.move_cursor_down - self.key_func["M-u"] = self.move_cursor_to_next_contact - self.key_func["M-y"] = self.move_cursor_to_prev_contact - self.key_func["M-U"] = self.move_cursor_to_next_group - self.key_func["M-Y"] = self.move_cursor_to_prev_group - self.key_func["M-[1;5B"] = self.move_cursor_to_next_group - self.key_func["M-[1;5A"] = self.move_cursor_to_prev_group - self.key_func["l"] = self.command_last_activity - self.key_func["o"] = self.toggle_offline_show - self.key_func["v"] = self.get_contact_version - self.key_func["i"] = self.show_contact_info - self.key_func["n"] = self.change_contact_name - self.key_func["s"] = self.start_search - self.key_func["S"] = self.start_search_slow - self.register_command('deny', self.command_deny, - usage=_('[jid]'), - desc=_('Deny your presence to the provided JID (or the selected contact in your roster), who is asking you to be in his/here roster.'), - shortdesc=_('Deny an user your presence.'), - completion=self.completion_deny) - self.register_command('accept', self.command_accept, - usage=_('[jid]'), - desc=_('Allow the provided JID (or the selected contact in your roster), to see your presence.'), - shortdesc=_('Allow an user your presence.'), - completion=self.completion_deny) - self.register_command('add', self.command_add, - usage=_('<jid>'), - desc=_('Add the specified JID to your roster, ask him to allow you to see his presence, and allow him to see your presence.'), - shortdesc=_('Add an user to your roster.')) - self.register_command('name', self.command_name, - usage=_('<jid> <name>'), - shortdesc=_('Set the given JID\'s name.'), - completion=self.completion_name) - self.register_command('groupadd', self.command_groupadd, - usage=_('<jid> <group>'), - desc=_('Add the given JID to the given group.'), - shortdesc=_('Add an user to a group'), - completion=self.completion_groupadd) - self.register_command('groupmove', self.command_groupmove, - usage=_('<jid> <old group> <new group>'), - desc=_('Move the given JID from the old group to the new group.'), - shortdesc=_('Move an user to another group.'), - completion=self.completion_groupmove) - self.register_command('groupremove', self.command_groupremove, - usage=_('<jid> <group>'), - desc=_('Remove the given JID from the given group.'), - shortdesc=_('Remove an user from a group.'), - completion=self.completion_groupremove) - self.register_command('remove', self.command_remove, - usage=_('[jid]'), - desc=_('Remove the specified JID from your roster. This wil unsubscribe you from its presence, cancel its subscription to yours, and remove the item from your roster.'), - shortdesc=_('Remove an user from your roster.'), - completion=self.completion_remove) + # disable most of the roster features when in anonymous mode + if not self.core.xmpp.anon: + self.key_func[' '] = self.on_space + self.key_func["KEY_UP"] = self.move_cursor_up + self.key_func["KEY_DOWN"] = self.move_cursor_down + self.key_func["M-u"] = self.move_cursor_to_next_contact + self.key_func["M-y"] = self.move_cursor_to_prev_contact + self.key_func["M-U"] = self.move_cursor_to_next_group + self.key_func["M-Y"] = self.move_cursor_to_prev_group + self.key_func["M-[1;5B"] = self.move_cursor_to_next_group + self.key_func["M-[1;5A"] = self.move_cursor_to_prev_group + self.key_func["l"] = self.command_last_activity + self.key_func["o"] = self.toggle_offline_show + self.key_func["v"] = self.get_contact_version + self.key_func["i"] = self.show_contact_info + self.key_func["s"] = self.start_search + self.key_func["S"] = self.start_search_slow + self.key_func["n"] = self.change_contact_name + self.register_command('deny', self.command_deny, + usage='[jid]', + desc='Deny your presence to the provided JID (or the ' + 'selected contact in your roster), who is asking' + 'you to be in his/here roster.', + shortdesc='Deny an user your presence.', + completion=self.completion_deny) + self.register_command('accept', self.command_accept, + usage='[jid]', + desc='Allow the provided JID (or the selected contact ' + 'in your roster), to see your presence.', + shortdesc='Allow an user your presence.', + completion=self.completion_deny) + self.register_command('add', self.command_add, + usage='<jid>', + desc='Add the specified JID to your roster, ask him to' + ' allow you to see his presence, and allow him to' + ' see your presence.', + shortdesc='Add an user to your roster.') + self.register_command('name', self.command_name, + usage='<jid> [name]', + shortdesc='Set the given JID\'s name.', + completion=self.completion_name) + self.register_command('groupadd', self.command_groupadd, + usage='<jid> <group>', + desc='Add the given JID to the given group.', + shortdesc='Add an user to a group', + completion=self.completion_groupadd) + self.register_command('groupmove', self.command_groupmove, + usage='<jid> <old group> <new group>', + desc='Move the given JID from the old group to the new group.', + shortdesc='Move an user to another group.', + completion=self.completion_groupmove) + self.register_command('groupremove', self.command_groupremove, + usage='<jid> <group>', + desc='Remove the given JID from the given group.', + shortdesc='Remove an user from a group.', + completion=self.completion_groupremove) + self.register_command('remove', self.command_remove, + usage='[jid]', + desc='Remove the specified JID from your roster. This ' + 'will unsubscribe you from its presence, cancel ' + 'its subscription to yours, and remove the item ' + 'from your roster.', + shortdesc='Remove an user from your roster.', + completion=self.completion_remove) + self.register_command('export', self.command_export, + usage='[/path/to/file]', + desc='Export your contacts into /path/to/file if ' + 'specified, or $HOME/poezio_contacts if not.', + shortdesc='Export your roster to a file.', + completion=partial(self.completion_file, 1)) + self.register_command('import', self.command_import, + usage='[/path/to/file]', + desc='Import your contacts from /path/to/file if ' + 'specified, or $HOME/poezio_contacts if not.', + shortdesc='Import your roster from a file.', + completion=partial(self.completion_file, 1)) + self.register_command('password', self.command_password, + usage='<password>', + shortdesc='Change your password') + self.register_command('reconnect', self.command_reconnect, - desc=_('Disconnect from the remote server if you are currently connected and then connect to it again.'), - shortdesc=_('Disconnect and reconnect to the server.')) + desc='Disconnect from the remote server if you are ' + 'currently connected and then connect to it again.', + shortdesc='Disconnect and reconnect to the server.') self.register_command('disconnect', self.command_disconnect, - desc=_('Disconnect from the remote server.'), - shortdesc=_('Disconnect from the server.')) - self.register_command('export', self.command_export, - usage=_('[/path/to/file]'), - desc=_('Export your contacts into /path/to/file if specified, or $HOME/poezio_contacts if not.'), - shortdesc=_('Export your roster to a file.'), - completion=self.completion_file) - self.register_command('import', self.command_import, - usage=_('[/path/to/file]'), - desc=_('Import your contacts from /path/to/file if specified, or $HOME/poezio_contacts if not.'), - shortdesc=_('Import your roster from a file.'), - completion=self.completion_file) + desc='Disconnect from the remote server.', + shortdesc='Disconnect from the server.') self.register_command('clear', self.command_clear, - shortdesc=_('Clear the info buffer.')) + shortdesc='Clear the info buffer.') self.register_command('last_activity', self.command_last_activity, - usage=_('<jid>'), - desc=_('Informs you of the last activity of a JID.'), - shortdesc=_('Get the activity of someone.'), + usage='<jid>', + desc='Informs you of the last activity of a JID.', + shortdesc='Get the activity of someone.', completion=self.core.completion_last_activity) - self.register_command('password', self.command_password, - usage='<password>', - shortdesc=_('Change your password')) self.resize() self.update_commands() self.update_keys() def check_blocking(self, features): - if 'urn:xmpp:blocking' in features: + if 'urn:xmpp:blocking' in features and not self.core.xmpp.anon: self.register_command('block', self.command_block, - usage=_('[jid]'), - shortdesc=_('Prevent a JID from talking to you.'), + usage='[jid]', + shortdesc='Prevent a JID from talking to you.', completion=self.completion_block) self.register_command('unblock', self.command_unblock, - usage=_('[jid]'), - shortdesc=_('Allow a JID to talk to you.'), + usage='[jid]', + shortdesc='Allow a JID to talk to you.', completion=self.completion_unblock) self.register_command('list_blocks', self.command_list_blocks, - shortdesc=_('Show the blocked contacts.')) + shortdesc='Show the blocked contacts.') self.core.xmpp.del_event_handler('session_start', self.check_blocking) self.core.xmpp.add_event_handler('blocked_message', self.on_blocked_message) + def check_saslexternal(self, features): + if 'urn:xmpp:saslcert:1' in features and not self.core.xmpp.anon: + self.register_command('certs', self.command_certs, + desc='List the fingerprints of certificates' + ' which can connect to your account.', + shortdesc='List allowed client certs.') + self.register_command('cert_add', self.command_cert_add, + desc='Add a client certificate to the authorized ones. ' + 'It must have an unique name and be contained in ' + 'a PEM file. [management] is a boolean indicating' + ' if a client connected using this certificate can' + ' manage the certificates itself.', + shortdesc='Add a client certificate.', + usage='<name> <certificate path> [management]', + completion=self.completion_cert_add) + self.register_command('cert_disable', self.command_cert_disable, + desc='Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will not be ' + 'forcefully disconnected.', + shortdesc='Disable a certificate', + usage='<name>') + self.register_command('cert_revoke', self.command_cert_revoke, + desc='Remove a certificate from the list ' + 'of allowed ones. Clients currently ' + 'using this certificate will be ' + 'forcefully disconnected.', + shortdesc='Revoke a certificate', + usage='<name>') + self.register_command('cert_fetch', self.command_cert_fetch, + desc='Retrieve a certificate with its ' + 'name. It will be stored in <path>.', + shortdesc='Fetch a certificate', + usage='<name> <path>', + completion=self.completion_cert_fetch) + + @command_args_parser.ignored + def command_certs(self): + """ + /certs + """ + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to retrieve the certificate list.', + 'Error') + return + certs = [] + for item in iq['sasl_certs']['items']: + users = '\n'.join(item['users']) + certs.append((item['name'], users)) + + if not certs: + return self.core.information('No certificates found', 'Info') + msg = 'Certificates:\n' + msg += '\n'.join(((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1])) for item in certs)) + self.core.information(msg, 'Info') + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb, timeout=3) + + @command_args_parser.quoted(2, 1) + def command_cert_add(self, args): + """ + /cert_add <name> <certfile> [cert-management] + """ + if not args or len(args) < 2: + return self.core.command_help('cert_add') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to add the certificate.', 'Error') + else: + self.core.information('Certificate added.', 'Info') + + name = args[0] + + try: + with open(args[1]) as fd: + crt = fd.read() + crt = crt.replace(ssl.PEM_FOOTER, '').replace(ssl.PEM_HEADER, '').replace(' ', '').replace('\n', '') + except Exception as e: + self.core.information('Unable to read the certificate: %s' % e, 'Error') + return + + if len(args) > 2: + management = args[2] + if management: + management = management.lower() + if management not in ('false', '0'): + management = True + else: + management = False + else: + management = False + else: + management = True + + self.core.xmpp.plugin['xep_0257'].add_cert(name, crt, callback=cb, + allow_management=management) + + def completion_cert_add(self, the_input): + """ + completion for /cert_add <name> <path> [management] + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + log.debug('%s %s %s', the_input.text, n, the_input.pos) + if n == 1: + return + elif n == 2: + return self.completion_file(2, the_input) + elif n == 3: + return the_input.new_completion(['true', 'false'], n) + + @command_args_parser.quoted(1) + def command_cert_disable(self, args): + """ + /cert_disable <name> + """ + if not args: + return self.core.command_help('cert_disable') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to disable the certificate.', 'Error') + else: + self.core.information('Certificate disabled.', 'Info') + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].disable_cert(name, callback=cb) + + @command_args_parser.quoted(1) + def command_cert_revoke(self, args): + """ + /cert_revoke <name> + """ + if not args: + return self.core.command_help('cert_revoke') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to revoke the certificate.', 'Error') + else: + self.core.information('Certificate revoked.', 'Info') + + name = args[0] + + self.core.xmpp.plugin['xep_0257'].revoke_cert(name, callback=cb) + + + @command_args_parser.quoted(2) + def command_cert_fetch(self, args): + """ + /cert_fetch <name> <path> + """ + if not args or len(args) < 2: + return self.core.command_help('cert_fetch') + def cb(iq): + if iq['type'] == 'error': + self.core.information('Unable to fetch the certificate.', + 'Error') + return + + cert = None + for item in iq['sasl_certs']['items']: + if item['name'] == name: + cert = base64.b64decode(item['x509cert']) + break + + if not cert: + return self.core.information('Certificate not found.', 'Info') + + cert = ssl.DER_cert_to_PEM_cert(cert) + with open(path, 'w') as fd: + fd.write(cert) + + self.core.information('File stored at %s' % path, 'Info') + + name = args[0] + path = args[1] + + self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb) + + def completion_cert_fetch(self, the_input): + """ + completion for /cert_fetch <name> <path> + """ + text = the_input.get_text() + args = common.shell_split(text) + n = the_input.get_argument_position() + log.debug('%s %s %s', the_input.text, n, the_input.pos) + if n == 1: + return + elif n == 2: + return self.completion_file(2, the_input) + def on_blocked_message(self, message): """ When we try to send a message to a blocked contact @@ -158,7 +368,8 @@ class RosterInfoTab(Tab): } tab.add_message(message) - def command_block(self, arg): + @command_args_parser.quoted(0, 1) + def command_block(self, args): """ /block [jid] """ @@ -169,8 +380,8 @@ class RosterInfoTab(Tab): return self.core.information('Contact blocked.', 'Info') item = self.roster_win.selected_row - if arg: - jid = safeJID(arg) + if args: + jid = safeJID(args[0]) elif isinstance(item, Contact): jid = item.bare_jid elif isinstance(item, Resource): @@ -185,7 +396,8 @@ class RosterInfoTab(Tab): jids = roster.jids() return the_input.new_completion(jids, 1, '', quotify=False) - def command_unblock(self, arg): + @command_args_parser.quoted(0, 1) + def command_unblock(self, args): """ /unblock [jid] """ @@ -196,8 +408,8 @@ class RosterInfoTab(Tab): return self.core.information('Contact unblocked.', 'Info') item = self.roster_win.selected_row - if arg: - jid = safeJID(arg) + if args: + jid = safeJID(args[0]) elif isinstance(item, Contact): jid = item.bare_jid elif isinstance(item, Resource): @@ -218,7 +430,8 @@ class RosterInfoTab(Tab): self.core.xmpp.plugin['xep_0191'].get_blocked(callback=on_result) return True - def command_list_blocks(self, arg=None): + @command_args_parser.ignored + def command_list_blocks(self): """ /list_blocks """ @@ -236,7 +449,8 @@ class RosterInfoTab(Tab): self.core.xmpp.plugin['xep_0191'].get_blocked(callback=callback) - def command_reconnect(self, args=None): + @command_args_parser.ignored + def command_reconnect(self): """ /reconnect """ @@ -245,19 +459,21 @@ class RosterInfoTab(Tab): else: self.core.xmpp.connect() - def command_disconnect(self, args=None): + @command_args_parser.ignored + def command_disconnect(self): """ /disconnect """ self.core.disconnect() - def command_last_activity(self, arg=None): + @command_args_parser.quoted(0, 1) + def command_last_activity(self, args): """ /activity [jid] """ item = self.roster_win.selected_row - if arg: - jid = arg + if args: + jid = args[0] elif isinstance(item, Contact): jid = item.bare_jid elif isinstance(item, Resource): @@ -311,31 +527,45 @@ class RosterInfoTab(Tab): not self.input.help_message: self.complete_commands(self.input) - def completion_file(self, the_input): + def completion_file(self, complete_number, the_input): """ - Completion for /import and /export + Generic quoted completion for files/paths + (use functools.partial to use directly as a completion + for a command) """ text = the_input.get_text() - args = text.split() - n = len(args) - if n == 1: - home = os.getenv('HOME') or '/' - return the_input.auto_completion([home, '/tmp'], '') - else: - the_path = text[text.index(' ')+1:] + args = common.shell_split(text) + n = the_input.get_argument_position() + if n == complete_number: + if args[n-1] == '' or len(args) < n+1: + home = os.getenv('HOME') or '/' + return the_input.new_completion([home, '/tmp'], n, quotify=True) + path_ = args[n] + if path.isdir(path_): + dir_ = path_ + base = '' + else: + dir_ = path.dirname(path_) + base = path.basename(path_) try: - names = os.listdir(the_path) - except: + names = os.listdir(dir_) + except OSError: names = [] + names_filtered = [name for name in names if name.startswith(base)] + if names_filtered: + names = names_filtered + if not names: + names = [path_] end_list = [] for name in names: - value = os.path.join(the_path, name) + value = os.path.join(dir_, name) if not name.startswith('.'): end_list.append(value) - return the_input.auto_completion(end_list, '') + return the_input.new_completion(end_list, n, quotify=True) - def command_clear(self, arg=''): + @command_args_parser.ignored + def command_clear(self): """ /clear """ @@ -344,7 +574,8 @@ class RosterInfoTab(Tab): self.core.information_win.rebuild_everything(self.core.information_buffer) self.refresh() - def command_password(self, arg): + @command_args_parser.quoted(1) + def command_password(self, args): """ /password <password> """ @@ -352,19 +583,18 @@ class RosterInfoTab(Tab): if iq['type'] == 'result': self.core.information('Password updated', 'Account') if config.get('password'): - config.silent_set('password', arg) + config.silent_set('password', args[0]) else: self.core.information('Unable to change the password', 'Account') - self.core.xmpp.plugin['xep_0077'].change_password(arg, callback=callback) - + self.core.xmpp.plugin['xep_0077'].change_password(args[0], callback=callback) - - def command_deny(self, arg): + @command_args_parser.quoted(0, 1) + def command_deny(self, args): """ /deny [jid] Denies a JID from our roster """ - if not arg: + if not args: item = self.roster_win.selected_row if isinstance(item, Contact): jid = item.bare_jid @@ -372,7 +602,7 @@ class RosterInfoTab(Tab): self.core.information('No subscription to deny') return else: - jid = safeJID(arg).bare + jid = safeJID(args[0]).bare if not jid in [jid for jid in roster.jids()]: self.core.information('No subscription to deny') return @@ -383,14 +613,15 @@ class RosterInfoTab(Tab): self.core.information('Subscription to %s was revoked' % jid, 'Roster') + @command_args_parser.quoted(1) def command_add(self, args): """ Add the specified JID to the roster, and set automatically accept the reverse subscription """ - jid = safeJID(safeJID(args.strip()).bare) + jid = safeJID(safeJID(args[0]).bare) if not jid: - self.core.information(_('No JID specified'), 'Error') + self.core.information('No JID specified', 'Error') return if jid in roster and roster[jid].subscription in ('to', 'both'): return self.core.information('Already subscribed.', 'Roster') @@ -398,7 +629,8 @@ class RosterInfoTab(Tab): roster.modified() self.core.information('%s was added to the roster' % jid, 'Roster') - def command_name(self, arg): + @command_args_parser.quoted(1, 1) + def command_name(self, args): """ Set a name for the specified JID in your roster """ @@ -406,15 +638,14 @@ class RosterInfoTab(Tab): if not iq: self.core.information('The name could not be set.', 'Error') log.debug('Error in /name:\n%s', iq) - args = common.shell_split(arg) - if not args: + if args is None: return self.core.command_help('name') jid = safeJID(args[0]).bare name = args[1] if len(args) == 2 else '' contact = roster[jid] if contact is None: - self.core.information(_('No such JID in roster'), 'Error') + self.core.information('No such JID in roster', 'Error') return groups = set(contact.groups) @@ -424,24 +655,24 @@ class RosterInfoTab(Tab): self.core.xmpp.update_roster(jid, name=name, groups=groups, subscription=subscription, callback=callback) + @command_args_parser.quoted(2) def command_groupadd(self, args): """ Add the specified JID to the specified group """ - args = common.shell_split(args) - if len(args) != 2: - return + if args is None: + return self.core.command_help('groupadd') jid = safeJID(args[0]).bare group = args[1] contact = roster[jid] if contact is None: - self.core.information(_('No such JID in roster'), 'Error') + self.core.information('No such JID in roster', 'Error') return new_groups = set(contact.groups) if group in new_groups: - self.core.information(_('JID already in group'), 'Error') + self.core.information('JID already in group', 'Error') return roster.modified() @@ -464,12 +695,12 @@ class RosterInfoTab(Tab): self.core.xmpp.update_roster(jid, name=name, groups=new_groups, subscription=subscription, callback=callback) - def command_groupmove(self, arg): + @command_args_parser.quoted(3) + def command_groupmove(self, args): """ Remove the specified JID from the first specified group and add it to the second one """ - args = common.shell_split(arg) - if len(args) != 3: + if args is None: return self.core.command_help('groupmove') jid = safeJID(args[0]).bare group_from = args[1] @@ -477,7 +708,7 @@ class RosterInfoTab(Tab): contact = roster[jid] if not contact: - self.core.information(_('No such JID in roster'), 'Error') + self.core.information('No such JID in roster', 'Error') return new_groups = set(contact.groups) @@ -485,19 +716,19 @@ class RosterInfoTab(Tab): new_groups.remove('none') if group_to == 'none' or group_from == 'none': - self.core.information(_('"none" is not a group.'), 'Error') + self.core.information('"none" is not a group.', 'Error') return if group_from not in new_groups: - self.core.information(_('JID not in first group'), 'Error') + self.core.information('JID not in first group', 'Error') return if group_to in new_groups: - self.core.information(_('JID already in second group'), 'Error') + self.core.information('JID already in second group', 'Error') return if group_to == group_from: - self.core.information(_('The groups are the same.'), 'Error') + self.core.information('The groups are the same.', 'Error') return roster.modified() @@ -519,19 +750,20 @@ class RosterInfoTab(Tab): self.core.xmpp.update_roster(jid, name=name, groups=new_groups, subscription=subscription, callback=callback) + @command_args_parser.quoted(2) def command_groupremove(self, args): """ Remove the specified JID from the specified group """ - args = common.shell_split(args) - if len(args) != 2: - return + if args is None: + return self.core.command_help('groupremove') + jid = safeJID(args[0]).bare group = args[1] contact = roster[jid] if contact is None: - self.core.information(_('No such JID in roster'), 'Error') + self.core.information('No such JID in roster', 'Error') return new_groups = set(contact.groups) @@ -540,7 +772,7 @@ class RosterInfoTab(Tab): except KeyError: pass if group not in new_groups: - self.core.information(_('JID not in group'), 'Error') + self.core.information('JID not in group', 'Error') return roster.modified() @@ -559,13 +791,14 @@ class RosterInfoTab(Tab): self.core.xmpp.update_roster(jid, name=name, groups=new_groups, subscription=subscription, callback=callback) + @command_args_parser.quoted(0, 1) def command_remove(self, args): """ Remove the specified JID from the roster. i.e.: unsubscribe from its presence, and cancel its subscription to our. """ - if args.strip(): - jid = safeJID(args.strip()).bare + if args: + jid = safeJID(args[0]).bare else: item = self.roster_win.selected_row if isinstance(item, Contact): @@ -576,12 +809,12 @@ class RosterInfoTab(Tab): roster.remove(jid) del roster[jid] - def command_import(self, arg): + @command_args_parser.quoted(0, 1) + def command_import(self, args): """ Import the contacts """ - args = common.shell_split(arg) - if len(args): + if args: if args[0].startswith('/'): filepath = args[0] else: @@ -603,12 +836,12 @@ class RosterInfoTab(Tab): self.command_add(jid.lstrip('\n')) self.core.information('Contacts imported from %s' % filepath, 'Info') - def command_export(self, arg): + @command_args_parser.quoted(0, 1) + def command_export(self, args): """ Export the contacts """ - args = common.shell_split(arg) - if len(args): + if args: if args[0].startswith('/'): filepath = args[0] else: @@ -697,11 +930,12 @@ class RosterInfoTab(Tab): if contact.pending_in) return the_input.new_completion(jids, 1, '', quotify=False) - def command_accept(self, arg): + @command_args_parser.quoted(0, 1) + def command_accept(self, args): """ Accept a JID from in roster. Authorize it AND subscribe to it """ - if not arg: + if not args: item = self.roster_win.selected_row if isinstance(item, Contact): jid = item.bare_jid @@ -709,7 +943,7 @@ class RosterInfoTab(Tab): self.core.information('No subscription to accept') return else: - jid = safeJID(arg).bare + jid = safeJID(args[0]).bare nodepart = safeJID(jid).user jid = safeJID(jid) # crappy transports putting resources inside the node part @@ -769,13 +1003,15 @@ class RosterInfoTab(Tab): success = config.silent_set(option, str(not value)) roster.modified() if not success: - self.core.information(_('Unable to write in the config file'), 'Error') + self.core.information('Unable to write in the config file', 'Error') return True def on_slash(self): """ '/' is pressed, we enter "input mode" """ + if isinstance(self.input, windows.YesNoInput): + return curses.curs_set(1) self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command) self.input.resize(1, self.width, self.height-1, 0) @@ -951,6 +1187,8 @@ class RosterInfoTab(Tab): Start the search. The input should appear with a short instruction in it. """ + if isinstance(self.input, windows.YesNoInput): + return curses.curs_set(1) self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter) self.input.resize(1, self.width, self.height-1, 0) @@ -961,6 +1199,8 @@ class RosterInfoTab(Tab): @refresh_wrapper.always def start_search_slow(self): + if isinstance(self.input, windows.YesNoInput): + return curses.curs_set(1) self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter_slow) self.input.resize(1, self.width, self.height-1, 0) diff --git a/src/tabs/xmltab.py b/src/tabs/xmltab.py index 083e97c5..6899cd6f 100644 --- a/src/tabs/xmltab.py +++ b/src/tabs/xmltab.py @@ -5,52 +5,104 @@ in order to only show the relevant ones, and it can also be frozen or unfrozen on demand so that the relevant information is not drowned by the traffic. """ -from gettext import gettext as _ - import logging log = logging.getLogger(__name__) import curses import os from slixmpp.xmlstream import matcher -from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.tostring import tostring +from slixmpp.xmlstream.stanzabase import ElementBase +from xml.etree import ElementTree as ET from . import Tab +import text_buffer import windows from xhtml import clean_text +from decorators import command_args_parser +from common import safeJID + + +class MatchJID(object): + + def __init__(self, jid, dest=''): + self.jid = jid + self.dest = dest + + def match(self, xml): + from_ = safeJID(xml['from']) + to_ = safeJID(xml['to']) + if self.jid.full == self.jid.bare: + from_ = from_.bare + to_ = to_.bare + + if self.dest == 'from': + return from_ == self.jid + elif self.dest == 'to': + return to_ == self.jid + return self.jid in (from_, to_) + + def __repr__(self): + return '%s%s%s' % (self.dest, ': ' if self.dest else '', self.jid) + +MATCHERS_MAPPINGS = { + MatchJID: ('JID', lambda obj: repr(obj)), + matcher.MatcherId: ('ID', lambda obj: obj._criteria), + matcher.MatchXMLMask: ('XMLMask', lambda obj: tostring(obj._criteria)), + matcher.MatchXPath: ('XPath', lambda obj: obj._criteria) +} class XMLTab(Tab): def __init__(self): Tab.__init__(self) self.state = 'normal' self.name = 'XMLTab' - self.text_win = windows.TextWin() - self.core.xml_buffer.add_window(self.text_win) + self.filters = [] + + self.core_buffer = self.core.xml_buffer + self.filtered_buffer = text_buffer.TextBuffer() + self.info_header = windows.XMLInfoWin() + self.text_win = windows.XMLTextWin() + self.core_buffer.add_window(self.text_win) self.default_help_message = windows.HelpText("/ to enter a command") + self.register_command('close', self.close, - shortdesc=_("Close this tab.")) + shortdesc="Close this tab.") self.register_command('clear', self.command_clear, - shortdesc=_('Clear the current buffer.')) + shortdesc='Clear the current buffer.') self.register_command('reset', self.command_reset, - shortdesc=_('Reset the stanza filter.')) + shortdesc='Reset the stanza filter.') self.register_command('filter_id', self.command_filter_id, usage='<id>', - desc=_('Show only the stanzas with the id <id>.'), - shortdesc=_('Filter by id.')) + desc='Show only the stanzas with the id <id>.', + shortdesc='Filter by id.') self.register_command('filter_xpath', self.command_filter_xpath, usage='<xpath>', - desc=_('Show only the stanzas matching the xpath <xpath>.'), - shortdesc=_('Filter by XPath.')) + desc='Show only the stanzas matching the xpath <xpath>.' + ' Any occurrences of %n will be replaced by jabber:client.', + shortdesc='Filter by XPath.') + self.register_command('filter_jid', self.command_filter_jid, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in from= or to=.', + shortdesc='Filter by JID.') + self.register_command('filter_from', self.command_filter_from, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in from=.', + shortdesc='Filter by JID from.') + self.register_command('filter_to', self.command_filter_to, + usage='<jid>', + desc='Show only the stanzas matching the jid <jid> in to=.', + shortdesc='Filter by JID to.') self.register_command('filter_xmlmask', self.command_filter_xmlmask, - usage=_('<xml mask>'), - desc=_('Show only the stanzas matching the given xml mask.'), - shortdesc=_('Filter by xml mask.')) + usage='<xml mask>', + desc='Show only the stanzas matching the given xml mask.', + shortdesc='Filter by xml mask.') self.register_command('dump', self.command_dump, - usage=_('<filename>'), - desc=_('Writes the content of the XML buffer into a file.'), - shortdesc=_('Write in a file.')) + usage='<filename>', + desc='Writes the content of the XML buffer into a file.', + shortdesc='Write in a file.') self.input = self.default_help_message self.key_func['^T'] = self.close self.key_func['^I'] = self.completion @@ -63,6 +115,34 @@ class XMLTab(Tab): self.filter_type = '' self.filter = '' + def gen_filter_repr(self): + if not self.filters: + self.filter_type = '' + self.filter = '' + return + filter_types = map(lambda x: MATCHERS_MAPPINGS[type(x)][0], self.filters) + filter_strings = map(lambda x: MATCHERS_MAPPINGS[type(x)][1](x), self.filters) + self.filter_type = ','.join(filter_types) + self.filter = ','.join(filter_strings) + + def update_filters(self, matcher): + if not self.filters: + messages = self.core_buffer.messages[:] + self.filtered_buffer.messages = [] + self.core_buffer.del_window(self.text_win) + self.filtered_buffer.add_window(self.text_win) + else: + messages = self.filtered_buffer.messages + self.filtered_buffer.messages = [] + self.filters.append(matcher) + new_messages = [] + for msg in messages: + if self.match_stanza(ElementBase(ET.fromstring(clean_text(msg.txt)))): + new_messages.append(msg) + self.filtered_buffer.messages = new_messages + self.text_win.rebuild_everything(self.filtered_buffer) + self.gen_filter_repr() + def on_freeze(self): """ Freeze the display. @@ -70,58 +150,94 @@ class XMLTab(Tab): self.text_win.toggle_lock() self.refresh() - def command_filter_xmlmask(self, arg): + def match_stanza(self, stanza): + for matcher in self.filters: + if not matcher.match(stanza): + return False + return True + + @command_args_parser.raw + def command_filter_xmlmask(self, mask): """/filter_xmlmask <xml mask>""" try: - handler = Callback('custom matcher', matcher.MatchXMLMask(arg), - self.core.incoming_stanza) - self.core.xmpp.remove_handler('custom matcher') - self.core.xmpp.register_handler(handler) - self.filter_type = "XML Mask Filter" - self.filter = arg + self.update_filters(matcher.MatchXMLMask(mask)) self.refresh() - except: - self.core.information('Invalid XML Mask', 'Error') + except Exception as e: + self.core.information('Invalid XML Mask: %s' % e, 'Error') self.command_reset('') - def command_filter_id(self, arg): + @command_args_parser.raw + def command_filter_to(self, jid): + """/filter_jid_to <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='to')) + self.refresh() + + @command_args_parser.raw + def command_filter_from(self, jid): + """/filter_jid_from <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='from')) + self.refresh() + + @command_args_parser.raw + def command_filter_jid(self, jid): + """/filter_jid <jid>""" + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj)) + self.refresh() + + @command_args_parser.quoted(1) + def command_filter_id(self, args): """/filter_id <id>""" - self.core.xmpp.remove_handler('custom matcher') - handler = Callback('custom matcher', matcher.MatcherId(arg), - self.core.incoming_stanza) - self.core.xmpp.register_handler(handler) - self.filter_type = "Id Filter" - self.filter = arg + if args is None: + return self.core.command_help('filter_id') + + self.update_filters(matcher.MatcherId(args[0])) self.refresh() - def command_filter_xpath(self, arg): + @command_args_parser.raw + def command_filter_xpath(self, xpath): """/filter_xpath <xpath>""" try: - handler = Callback('custom matcher', matcher.MatchXPath( - arg.replace('%n', self.core.xmpp.default_ns)), - self.core.incoming_stanza) - self.core.xmpp.remove_handler('custom matcher') - self.core.xmpp.register_handler(handler) - self.filter_type = "XPath Filter" - self.filter = arg + self.update_filters(matcher.MatchXPath(xpath.replace('%n', self.core.xmpp.default_ns))) self.refresh() except: self.core.information('Invalid XML Path', 'Error') self.command_reset('') - def command_reset(self, arg): + @command_args_parser.ignored + def command_reset(self): """/reset""" - self.core.xmpp.remove_handler('custom matcher') - self.core.xmpp.register_handler(self.core.all_stanzas) + if self.filters: + self.filters = [] + self.filtered_buffer.del_window(self.text_win) + self.core_buffer.add_window(self.text_win) + self.text_win.rebuild_everything(self.core_buffer) self.filter_type = '' self.filter = '' self.refresh() - def command_dump(self, arg): + @command_args_parser.quoted(1) + def command_dump(self, args): """/dump <filename>""" - xml = self.core.xml_buffer.messages[:] - text = '\n'.join(('%s %s' % (msg.str_time, clean_text(msg.txt)) for msg in xml)) - filename = os.path.expandvars(os.path.expanduser(arg)) + if args is None: + return self.core.command_help('dump') + if self.filters: + xml = self.filtered_buffer.messages[:] + else: + xml = self.core_buffer.messages[:] + text = '\n'.join(('%s %s %s' % (msg.str_time, msg.nickname, clean_text(msg.txt)) for msg in xml)) + filename = os.path.expandvars(os.path.expanduser(args[0])) try: with open(filename, 'w') as fd: fd.write(text) @@ -151,12 +267,17 @@ class XMLTab(Tab): def on_scroll_down(self): return self.text_win.scroll_down(self.text_win.height-1) - def command_clear(self, args): + @command_args_parser.ignored + def command_clear(self): """ /clear """ - self.core.xml_buffer.messages = [] - self.text_win.rebuild_everything(self.core.xml_buffer) + if self.filters: + buffer = self.core_buffer + else: + buffer = self.filtered_buffer + buffer.messages = [] + self.text_win.rebuild_everything(buffer) self.refresh() self.core.doupdate() diff --git a/src/text_buffer.py b/src/text_buffer.py index 59aa96e1..6bc3ee23 100644 --- a/src/text_buffer.py +++ b/src/text_buffer.py @@ -24,6 +24,9 @@ Message = collections.namedtuple('Message', message_fields) class CorrectionError(Exception): pass +class AckError(Exception): + pass + def other_elems(self): "Helper for the repr_message function" acc = ['Message('] @@ -84,7 +87,7 @@ class TextBuffer(object): @staticmethod def make_message(txt, time, nickname, nick_color, history, user, identifier, str_time=None, highlight=False, - old_message=None, revisions=0, jid=None, ack=None): + old_message=None, revisions=0, jid=None, ack=0): """ Create a new Message object with parameters, check for /me messages, and delayed messages @@ -125,7 +128,7 @@ class TextBuffer(object): def add_message(self, txt, time=None, nickname=None, nick_color=None, history=None, user=None, highlight=False, - identifier=None, str_time=None, jid=None, ack=None): + identifier=None, str_time=None, jid=None, ack=0): """ Create a message and add it to the text buffer """ @@ -161,16 +164,31 @@ class TextBuffer(object): return i return -1 - def ack_message(self, old_id): + def ack_message(self, old_id, jid): + """Mark a message as acked""" + return self.edit_ack(1, old_id, jid) + + def nack_message(self, error, old_id, jid): + """Mark a message as errored""" + return self.edit_ack(-1, old_id, jid, append=error) + + def edit_ack(self, value, old_id, jid, append=''): """ - Ack a message + Edit the ack status of a message, and optionally + append some text. """ i = self._find_message(old_id) if i == -1: return msg = self.messages[i] + if msg.jid != jid: + raise AckError('Wrong JID for message id %s (was %s, expected %s)' % + (old_id, msg.jid, jid)) + new_msg = list(msg) - new_msg[12] = True + new_msg[12] = value + if append: + new_msg[0] = new_msg[0] + append new_msg = Message(*new_msg) self.messages[i] = new_msg return new_msg diff --git a/src/theming.py b/src/theming.py index 1e9d6c40..ae71e48f 100755 --- a/src/theming.py +++ b/src/theming.py @@ -69,20 +69,18 @@ log = logging.getLogger(__name__) from config import config import curses -import imp import os from os import path -from sys import version_info -if version_info[1] >= 3: - from importlib import machinery - finder = machinery.PathFinder() +from importlib import machinery +finder = machinery.PathFinder() class Theme(object): """ - The theme class, from which all theme should inherit. - All of the following value can be replaced in subclasses, in + The theme class, from which all themes should inherit. + All of the following values can be replaced in subclasses, in order to create a new theme. + Do not edit this file if you want to change the theme to suit your needs. Create a new theme and share it if you think it can be useful for others. @@ -178,6 +176,13 @@ class Theme(object): CHAR_AFFILIATION_MEMBER = '+' CHAR_AFFILIATION_NONE = '-' + + # XML Tab + CHAR_XML_IN = 'IN ' + CHAR_XML_OUT = 'OUT' + COLOR_XML_IN = (1, -1) + COLOR_XML_OUT = (2, -1) + # Color for the /me message COLOR_ME_MESSAGE = (6, -1) @@ -305,6 +310,7 @@ class Theme(object): CHAR_ERROR = '✖' CHAR_EMPTY = ' ' CHAR_ACK_RECEIVED = CHAR_OK + CHAR_NACK = CHAR_ERROR CHAR_COLUMN_ASC = ' ▲' CHAR_COLUMN_DESC = ' ▼' CHAR_ROSTER_ERROR = CHAR_ERROR @@ -319,6 +325,7 @@ class Theme(object): CHAR_ROSTER_NONE = '⇹' COLOR_CHAR_ACK = (2, -1) + COLOR_CHAR_NACK = (1, -1) COLOR_ROSTER_GAMING = (6, -1) COLOR_ROSTER_MOOD = (2, -1) @@ -493,21 +500,13 @@ def reload_theme(): new_theme = None exc = None try: - if version_info[1] < 3: - file, filename, info = imp.find_module(theme_name, load_path) - imp.acquire_lock() - new_theme = imp.load_module(theme_name, file, filename, info) - else: - loader = finder.find_module(theme_name, load_path) - if not loader: - return 'Failed to load the theme %s' % theme_name - new_theme = loader.load_module() + loader = finder.find_module(theme_name, load_path) + if not loader: + return 'Failed to load the theme %s' % theme_name + new_theme = loader.load_module() except Exception as e: log.error('Failed to load the theme %s', theme_name, exc_info=True) exc = e - finally: - if version_info[1] < 3 and imp.lock_held(): - imp.release_lock() if not new_theme: return 'Failed to load theme: %s' % exc diff --git a/src/user.py b/src/user.py index 0d29569f..b1796bc3 100644 --- a/src/user.py +++ b/src/user.py @@ -12,9 +12,14 @@ An user is a MUC participant, not a roster contact (see contact.py) from random import choice from datetime import timedelta, datetime +from hashlib import md5 +import xhtml from theming import get_theme +import logging +log = logging.getLogger(__name__) + ROLE_DICT = { '':0, 'none':0, @@ -27,14 +32,26 @@ class User(object): """ keep trace of an user in a Room """ - def __init__(self, nick, affiliation, show, status, role, jid): + def __init__(self, nick, affiliation, show, status, role, jid, deterministic=True, color=''): self.last_talked = datetime(1, 1, 1) # The oldest possible time self.update(affiliation, show, status, role) self.change_nick(nick) - self.color = choice(get_theme().LIST_COLOR_NICKNAMES) + if color != '': + self.change_color(color, deterministic) + else: + if deterministic: + self.set_deterministic_color() + else: + self.color = choice(get_theme().LIST_COLOR_NICKNAMES) self.jid = jid self.chatstate = None + def set_deterministic_color(self): + theme = get_theme() + mod = len(theme.LIST_COLOR_NICKNAMES) + nick_pos = int(md5(self.nick.encode('utf-8')).hexdigest(), 16) % mod + self.color = theme.LIST_COLOR_NICKNAMES[nick_pos] + def update(self, affiliation, show, status, role): self.affiliation = affiliation self.show = show @@ -46,6 +63,17 @@ class User(object): def change_nick(self, nick): self.nick = nick + def change_color(self, color_name, deterministic=False): + color = xhtml.colors.get(color_name) + if color == None: + log.error('Unknown color "%s"' % color_name) + if deterministic: + self.set_deterministic_color() + else: + self.color = choice(get_theme().LIST_COLOR_NICKNAMES) + else: + self.color = (color, -1) + def set_last_talked(self, time): """ time: datetime object diff --git a/src/windows/__init__.py b/src/windows/__init__.py index 9e165201..5ec73961 100644 --- a/src/windows/__init__.py +++ b/src/windows/__init__.py @@ -5,15 +5,16 @@ used to display information on the screen from . base_wins import Win from . data_forms import FormWin +from . bookmark_forms import BookmarksWin from . info_bar import GlobalInfoBar, VerticalGlobalInfoBar from . info_wins import InfoWin, XMLInfoWin, PrivateInfoWin, MucListInfoWin, \ ConversationInfoWin, DynamicConversationInfoWin, MucInfoWin, \ - ConversationStatusMessageWin + ConversationStatusMessageWin, BookmarksInfoWin from . input_placeholders import HelpText, YesNoInput from . inputs import Input, HistoryInput, MessageInput, CommandInput from . list import ListWin, ColumnHeaderWin from . misc import VerticalSeparator from . muc import UserList, Topic from . roster_win import RosterWin, ContactInfoWin -from . text_win import TextWin +from . text_win import TextWin, XMLTextWin diff --git a/src/windows/bookmark_forms.py b/src/windows/bookmark_forms.py new file mode 100644 index 00000000..7cbd30cc --- /dev/null +++ b/src/windows/bookmark_forms.py @@ -0,0 +1,278 @@ +""" +Windows used inthe bookmarkstab +""" +import curses + +from . import Win +from . inputs import Input +from . data_forms import FieldInput +from theming import to_curses_attr, get_theme +from common import safeJID + +class BookmarkJIDInput(FieldInput, Input): + def __init__(self, field): + FieldInput.__init__(self, field) + Input.__init__(self) + jid = safeJID(field.jid) + jid.resource = field.nick + self.text = jid.full + self.pos = len(self.text) + self.color = get_theme().COLOR_NORMAL_TEXT + + def save(self): + jid = safeJID(self.get_text()) + self._field.jid = jid.bare + self._field.name = jid.bare + self._field.nick = jid.resource + + def get_help_message(self): + return 'Edit the text' + +class BookmarkMethodInput(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + self.options = ('local', 'remote') + # val_pos is the position of the currently selected option + self.val_pos = self.options.index(field.method) + + def do_command(self, key): + if key == 'KEY_LEFT': + if self.val_pos > 0: + self.val_pos -= 1 + elif key == 'KEY_RIGHT': + if self.val_pos < len(self.options)-1: + self.val_pos += 1 + else: + return + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + self.addnstr(0, 0, ' '*self.width, self.width) + if self.val_pos > 0: + self.addstr(0, 0, '←') + if self.val_pos < len(self.options)-1: + self.addstr(0, self.width-1, '→') + if self.options: + option = self.options[self.val_pos] + self.addstr(0, self.width//2-len(option)//2, option) + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def save(self): + self._field.method = self.options[self.val_pos] + + def get_help_message(self): + return '←, →: Select a value amongst the others' + +class BookmarkPasswordInput(FieldInput, Input): + def __init__(self, field): + FieldInput.__init__(self, field) + Input.__init__(self) + self.text = field.password or '' + self.pos = len(self.text) + self.color = get_theme().COLOR_NORMAL_TEXT + + def rewrite_text(self): + self._win.erase() + if self.color: + self._win.attron(to_curses_attr(self.color)) + self.addstr('*'*len(self.text[self.view_pos:self.view_pos+self.width-1])) + if self.color: + (y, x) = self._win.getyx() + size = self.width-x + self.addnstr(' '*size, size, to_curses_attr(self.color)) + self.addstr(0, self.pos, '') + if self.color: + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def save(self): + self._field.password = self.get_text() or None + + def get_help_message(self): + return 'Edit the secret text' + +class BookmarkAutojoinWin(FieldInput, Win): + def __init__(self, field): + FieldInput.__init__(self, field) + Win.__init__(self) + self.last_key = 'KEY_RIGHT' + self.value = field.autojoin + + def do_command(self, key): + if key == 'KEY_LEFT' or key == 'KEY_RIGHT': + self.value = not self.value + self.last_key = key + self.refresh() + + def refresh(self): + self._win.erase() + self._win.attron(to_curses_attr(self.color)) + format_string = '←{:^%s}→' % 7 + inp = format_string.format(repr(self.value)) + self.addstr(0, 0, inp) + if self.last_key == 'KEY_RIGHT': + self.move(0, 8) + else: + self.move(0, 0) + self._win.attroff(to_curses_attr(self.color)) + self._refresh() + + def save(self): + self._field.autojoin = self.value + + def get_help_message(self): + return '← and →: change the value between True and False' + + +class BookmarksWin(Win): + def __init__(self, bookmarks, height, width, y, x): + self._win = Win._tab_win.derwin(height, width, y, x) + self.scroll_pos = 0 + self._current_input = 0 + self.current_horizontal_input = 0 + self._bookmarks = list(bookmarks) + self.lines = [] + for bookmark in sorted(self._bookmarks, key=lambda x: x.jid): + self.lines.append((BookmarkJIDInput(bookmark), + BookmarkPasswordInput(bookmark), + BookmarkAutojoinWin(bookmark), + BookmarkMethodInput(bookmark))) + + @property + def current_input(self): + return self._current_input + + @current_input.setter + def current_input(self, value): + if 0 <= self._current_input < len(self.lines): + if 0 <= value < len(self.lines): + self.lines[self._current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self._current_input = value + else: + self._current_input = 0 + + def add_bookmark(self, bookmark): + self.lines.append((BookmarkJIDInput(bookmark), + BookmarkPasswordInput(bookmark), + BookmarkAutojoinWin(bookmark), + BookmarkMethodInput(bookmark))) + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_horizontal_input = 0 + self.current_input = len(self.lines) - 1 + if self.current_input - self.scroll_pos > self.height-1: + self.scroll_pos = self.current_input - self.height + 1 + self.refresh() + + def del_current_bookmark(self): + if self.lines: + bm = self.lines[self.current_input][0]._field + to_delete = self.current_input + self.current_input -= 1 + del self.lines[to_delete] + if self.scroll_pos: + self.scroll_pos -= 1 + self.refresh() + return bm + + def resize(self, height, width, y, x): + self.height = height + self.width = width + self._win = Win._tab_win.derwin(height, width, y, x) + # Adjust the scroll position, if resizing made the window too small + # for the cursor to be visible + while self.current_input - self.scroll_pos > self.height-1: + self.scroll_pos += 1 + + def go_to_next_line_input(self): + if not self.lines: + return + if self.current_input == len(self.lines) - 1: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + # Adjust the scroll position if the current_input would be outside + # of the visible area + if self.current_input + 1 - self.scroll_pos > self.height-1: + self.current_input += 1 + self.scroll_pos += 1 + self.refresh() + else: + self.current_input += 1 + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_previous_line_input(self): + if not self.lines: + return + if self.current_input == 0: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_input -= 1 + # Adjust the scroll position if the current_input would be outside + # of the visible area + if self.current_input < self.scroll_pos: + self.scroll_pos = self.current_input + self.refresh() + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_next_horizontal_input(self): + if not self.lines: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_horizontal_input += 1 + if self.current_horizontal_input > 3: + self.current_horizontal_input = 0 + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def go_to_previous_horizontal_input(self): + if not self.lines: + return + if self.current_horizontal_input == 0: + return + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT) + self.current_horizontal_input -= 1 + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + + def on_input(self, key): + if not self.lines: + return + self.lines[self.current_input][self.current_horizontal_input].do_command(key) + + def refresh(self): + # store the cursor status + self._win.erase() + y = - self.scroll_pos + for i in range(len(self.lines)): + self.lines[i][0].resize(1, self.width//3, y + 1, 0) + self.lines[i][1].resize(1, self.width//3, y + 1, self.width//3) + self.lines[i][2].resize(1, self.width//6, y + 1, 2*self.width//3) + self.lines[i][3].resize(1, self.width//6, y + 1, 5*self.width//6) + y += 1 + self._refresh() + for i, inp in enumerate(self.lines): + if i < self.scroll_pos: + continue + if i >= self.height + self.scroll_pos: + break + for j in range(4): + inp[j].refresh() + + if self.lines and self.current_input < self.height-1: + self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW) + self.lines[self.current_input][self.current_horizontal_input].refresh() + if not self.lines: + curses.curs_set(0) + else: + curses.curs_set(1) + + def refresh_current_input(self): + if self.lines: + self.lines[self.current_input][self.current_horizontal_input].refresh() + + def save(self): + for line in self.lines: + for item in line: + item.save() + diff --git a/src/windows/data_forms.py b/src/windows/data_forms.py index d6e2cc66..86f33350 100644 --- a/src/windows/data_forms.py +++ b/src/windows/data_forms.py @@ -469,4 +469,3 @@ class FormWin(object): return self.inputs[self.current_input]['input'].get_help_message() return '' - diff --git a/src/windows/funcs.py b/src/windows/funcs.py index d58d4683..f1401628 100644 --- a/src/windows/funcs.py +++ b/src/windows/funcs.py @@ -4,7 +4,6 @@ Standalone functions used by the modules import string -from config import config from . base_wins import FORMAT_CHAR, format_chars def find_first_format_char(text, chars=None): @@ -19,8 +18,7 @@ def find_first_format_char(text, chars=None): pos = p return pos -def truncate_nick(nick, size=None): - size = size or config.get('max_nick_length') +def truncate_nick(nick, size=10): if size < 1: size = 1 if nick and len(nick) > size: diff --git a/src/windows/info_bar.py b/src/windows/info_bar.py index e66343c5..abd956cd 100644 --- a/src/windows/info_bar.py +++ b/src/windows/info_bar.py @@ -28,6 +28,7 @@ class GlobalInfoBar(Win): show_names = config.get('show_tab_names') show_nums = config.get('show_tab_numbers') use_nicks = config.get('use_tab_nicks') + show_inactive = config.get('show_inactive_tabs') # ignore any remaining gap tabs if the feature is not enabled if create_gaps: sorted_tabs = self.core.tabs[:] @@ -37,8 +38,7 @@ class GlobalInfoBar(Win): for nb, tab in enumerate(sorted_tabs): if not tab: continue color = tab.color - if not config.get('show_inactive_tabs') and\ - color is get_theme().COLOR_TAB_NORMAL: + if not show_inactive and color is get_theme().COLOR_TAB_NORMAL: continue try: if show_nums or not show_names: @@ -87,9 +87,10 @@ class VerticalGlobalInfoBar(Win): sorted_tabs = sorted_tabs[-height:] else: sorted_tabs = sorted_tabs[pos-height//2 : pos+height//2] + asc_sort = (config.get('vertical_tab_list_sort') == 'asc') for y, tab in enumerate(sorted_tabs): color = tab.vertical_color - if not config.get('vertical_tab_list_sort') != 'asc': + if asc_sort: y = height - y - 1 self.addstr(y, 0, "%2d" % tab.nb, to_curses_attr(get_theme().COLOR_VERTICAL_TAB_NUMBER)) diff --git a/src/windows/info_wins.py b/src/windows/info_wins.py index 766afb75..80af4602 100644 --- a/src/windows/info_wins.py +++ b/src/windows/info_wins.py @@ -293,3 +293,17 @@ class ConversationStatusMessageWin(InfoWin): def write_status_message(self, resource): self.addstr(resource.status, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) +class BookmarksInfoWin(InfoWin): + def __init__(self): + InfoWin.__init__(self) + + def refresh(self, preferred): + log.debug('Refresh: %s', self.__class__.__name__) + self._win.erase() + self.write_remote_status(preferred) + self.finish_line(get_theme().COLOR_INFORMATION_BAR) + self._refresh() + + def write_remote_status(self, preferred): + self.addstr('Remote storage: %s' % preferred, to_curses_attr(get_theme().COLOR_INFORMATION_BAR)) + diff --git a/src/windows/input_placeholders.py b/src/windows/input_placeholders.py index 8bcf1524..496417d1 100644 --- a/src/windows/input_placeholders.py +++ b/src/windows/input_placeholders.py @@ -41,7 +41,7 @@ class YesNoInput(Win): A Window just displaying a Yes/No input Used to ask a confirmation """ - def __init__(self, text=''): + def __init__(self, text='', callback=None): Win.__init__(self) self.key_func = { 'y' : self.on_yes, @@ -49,6 +49,7 @@ class YesNoInput(Win): } self.txt = text self.value = None + self.callback = callback def on_yes(self): self.value = True @@ -68,17 +69,8 @@ class YesNoInput(Win): def do_command(self, key, raw=False): if key.lower() in self.key_func: self.key_func[key]() - - def prompt(self): - """Monopolizes the input while waiting for a recognized keypress""" - def cb(key): - if key in self.key_func: - self.key_func[key]() - if self.value is None: - # We didn’t finish with this prompt, continue monopolizing - # it again until value is set - keyboard.continuation_keys_callback = cb - keyboard.continuation_keys_callback = cb + if self.value is not None and self.callback is not None: + return self.callback() def on_delete(self): return diff --git a/src/windows/inputs.py b/src/windows/inputs.py index d345443b..12d3a9a2 100644 --- a/src/windows/inputs.py +++ b/src/windows/inputs.py @@ -43,6 +43,8 @@ class Input(Win): '^D': self.key_dc, 'M-b': self.jump_word_left, "M-[1;5D": self.jump_word_left, + "kRIT5": self.jump_word_right, + "kLFT5": self.jump_word_left, '^W': self.delete_word, 'M-d': self.delete_next_word, '^K': self.delete_end_of_line, @@ -534,6 +536,11 @@ class Input(Win): if self.view_pos < 0: self.view_pos = 0 + # text small enough to fit inside the window entirely: + # remove scrolling if present + if poopt.wcswidth(self.text) < self.width: + self.view_pos = 0 + assert(self.pos >= self.view_pos and self.pos <= self.view_pos + max(self.width, 3)) diff --git a/src/windows/muc.py b/src/windows/muc.py index 7e3541ba..c4e8df6e 100644 --- a/src/windows/muc.py +++ b/src/windows/muc.py @@ -37,7 +37,8 @@ class UserList(Win): if config.get('hide_user_list'): return # do not refresh if this win is hidden. self._win.erase() - if config.get('user_list_sort').lower() == 'asc': + asc_sort = (config.get('user_list_sort').lower() == 'asc') + if asc_sort: y, x = self._win.getmaxyx() y -= 1 users = sorted(users) @@ -55,7 +56,7 @@ class UserList(Win): self.addstr(y, 2, poopt.cut_by_columns(user.nick, self.width - 2), to_curses_attr(user.color)) - if config.get('user_list_sort').lower() == 'asc': + if asc_sort: y -= 1 else: y += 1 @@ -63,12 +64,12 @@ class UserList(Win): break # draw indicators of position in the list if self.pos > 0: - if config.get('user_list_sort').lower() == 'asc': + if asc_sort: self.draw_plus(self.height-1) else: self.draw_plus(0) if self.pos + self.height < len(users): - if config.get('user_list_sort').lower() == 'asc': + if asc_sort: self.draw_plus(0) else: self.draw_plus(self.height-1) diff --git a/src/windows/roster_win.py b/src/windows/roster_win.py index 6ecb6128..a2e2badd 100644 --- a/src/windows/roster_win.py +++ b/src/windows/roster_win.py @@ -145,6 +145,12 @@ class RosterWin(Win): # draw the roster from the cache roster_view = self.roster_cache[self.start_pos-1:self.start_pos+self.height] + options = { + 'show_roster_sub': config.get('show_roster_subscriptions'), + 'show_s2s_errors': config.get('show_s2s_errors'), + 'show_roster_jids': config.get('show_roster_jids') + } + for item in roster_view: draw_selected = False if y -2 + self.start_pos == self.pos: @@ -155,7 +161,7 @@ class RosterWin(Win): self.draw_group(y, item, draw_selected) group = item.name elif isinstance(item, Contact): - self.draw_contact_line(y, item, draw_selected, group) + self.draw_contact_line(y, item, draw_selected, group, **options) elif isinstance(item, Resource): self.draw_resource_line(y, item, draw_selected) @@ -206,7 +212,8 @@ class RosterWin(Win): return name return name[:self.width - added - 1] + '…' - def draw_contact_line(self, y, contact, colored, group): + def draw_contact_line(self, y, contact, colored, group, show_roster_sub=False, + show_s2s_errors=True, show_roster_jids=False): """ Draw on a line all informations about one contact. This is basically the highest priority resource's informations @@ -229,15 +236,13 @@ class RosterWin(Win): self.addstr(y, 0, ' ') self.addstr(theme.CHAR_STATUS, to_curses_attr(color)) - show_roster_sub = config.get('show_roster_subscriptions') - self.addstr(' ') if resource: self.addstr('[+] ' if contact.folded(group) else '[-] ') added += 4 if contact.ask: added += len(get_theme().CHAR_ROSTER_ASKED) - if config.get('show_s2s_errors') and contact.error: + if show_s2s_errors and contact.error: added += len(get_theme().CHAR_ROSTER_ERROR) if contact.tune: added += len(get_theme().CHAR_ROSTER_TUNE) @@ -250,7 +255,7 @@ class RosterWin(Win): if show_roster_sub in ('all', 'incomplete', 'to', 'from', 'both', 'none'): added += len(theme.char_subscription(contact.subscription, keep=show_roster_sub)) - if not config.get('show_roster_jids') and contact.name: + if not show_roster_jids and contact.name: display_name = '%s' % contact.name elif contact.name and contact.name != contact.bare_jid: display_name = '%s (%s)' % (contact.name, contact.bare_jid) @@ -268,7 +273,7 @@ class RosterWin(Win): self.addstr(theme.char_subscription(contact.subscription, keep=show_roster_sub), to_curses_attr(theme.COLOR_ROSTER_SUBSCRIPTION)) if contact.ask: self.addstr(get_theme().CHAR_ROSTER_ASKED, to_curses_attr(get_theme().COLOR_IMPORTANT_TEXT)) - if config.get('show_s2s_errors') and contact.error: + if show_s2s_errors and contact.error: self.addstr(get_theme().CHAR_ROSTER_ERROR, to_curses_attr(get_theme().COLOR_ROSTER_ERROR)) if contact.tune: self.addstr(get_theme().CHAR_ROSTER_TUNE, to_curses_attr(get_theme().COLOR_ROSTER_TUNE)) diff --git a/src/windows/text_win.py b/src/windows/text_win.py index 6fe74f41..59c5230b 100644 --- a/src/windows/text_win.py +++ b/src/windows/text_win.py @@ -18,7 +18,7 @@ from config import config from theming import to_curses_attr, get_theme, dump_tuple -class TextWin(Win): +class BaseTextWin(Win): def __init__(self, lines_nb_limit=None): if lines_nb_limit is None: lines_nb_limit = config.get('max_lines_in_memory') @@ -30,19 +30,6 @@ class TextWin(Win): self.lock = False self.lock_buffer = [] - - # the Lines of the highlights in that buffer - self.highlights = [] - # the current HL position in that list NaN means that we’re not on - # an hl. -1 is a valid position (it's before the first hl of the - # list. i.e the separator, in the case where there’s no hl before - # it.) - self.hl_pos = float('nan') - - # Keep track of the number of hl after the separator. - # This is useful to make “go to next highlight“ work after a “move to separator”. - self.nb_of_highlights_after_separator = 0 - self.separator_after = None def toggle_lock(self): @@ -60,6 +47,114 @@ class TextWin(Win): self.built_lines.append(line) self.lock = False + def scroll_up(self, dist=14): + pos = self.pos + self.pos += dist + if self.pos + self.height > len(self.built_lines): + self.pos = len(self.built_lines) - self.height + if self.pos < 0: + self.pos = 0 + return self.pos != pos + + def scroll_down(self, dist=14): + pos = self.pos + self.pos -= dist + if self.pos <= 0: + self.pos = 0 + return self.pos != pos + + def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False, nick_size=10): + """ + Take one message, build it and add it to the list + Return the number of lines that are built for the given + message. + """ + lines = self.build_message(message, timestamp=timestamp, nick_size=nick_size) + if self.lock: + self.lock_buffer.extend(lines) + else: + self.built_lines.extend(lines) + if not lines or not lines[0]: + return 0 + if clean: + while len(self.built_lines) > self.lines_nb_limit: + self.built_lines.pop(0) + return len(lines) + + def build_message(self, message, timestamp=False, nick_size=10): + """ + Build a list of lines from a message, without adding it + to a list + """ + pass + + def refresh(self): + pass + + def write_text(self, y, x, txt): + """ + write the text of a line. + """ + self.addstr_colored(txt, y, x) + + def write_time(self, time): + """ + Write the date on the yth line of the window + """ + if time: + self.addstr(time) + self.addstr(' ') + + def resize(self, height, width, y, x, room=None): + if hasattr(self, 'width'): + old_width = self.width + else: + old_width = None + self._resize(height, width, y, x) + if room and self.width != old_width: + self.rebuild_everything(room) + + # reposition the scrolling after resize + # (see #2450) + buf_size = len(self.built_lines) + if buf_size - self.pos < self.height: + self.pos = buf_size - self.height + if self.pos < 0: + self.pos = 0 + + def rebuild_everything(self, room): + self.built_lines = [] + with_timestamps = config.get('show_timestamps') + nick_size = config.get('max_nick_length') + for message in room.messages: + self.build_new_message(message, clean=False, timestamp=with_timestamps, nick_size=nick_size) + if self.separator_after is message: + self.build_new_message(None) + while len(self.built_lines) > self.lines_nb_limit: + self.built_lines.pop(0) + + def __del__(self): + log.debug('** TextWin: deleting %s built lines', (len(self.built_lines))) + del self.built_lines + +class TextWin(BaseTextWin): + def __init__(self, lines_nb_limit=None): + BaseTextWin.__init__(self, lines_nb_limit) + + # the Lines of the highlights in that buffer + self.highlights = [] + # the current HL position in that list NaN means that we’re not on + # an hl. -1 is a valid position (it's before the first hl of the + # list. i.e the separator, in the case where there’s no hl before + # it.) + self.hl_pos = float('nan') + + # Keep track of the number of hl after the separator. + # This is useful to make “go to next highlight“ work after a “move to separator”. + self.nb_of_highlights_after_separator = 0 + + self.separator_after = None + def next_highlight(self): """ Go to the next highlight in the buffer. @@ -130,22 +225,6 @@ class TextWin(Win): if self.pos < 0 or self.pos >= len(self.built_lines): self.pos = 0 - def scroll_up(self, dist=14): - pos = self.pos - self.pos += dist - if self.pos + self.height > len(self.built_lines): - self.pos = len(self.built_lines) - self.height - if self.pos < 0: - self.pos = 0 - return self.pos != pos - - def scroll_down(self, dist=14): - pos = self.pos - self.pos -= dist - if self.pos <= 0: - self.pos = 0 - return self.pos != pos - def scroll_to_separator(self): """ Scroll until separator is centered. If no separator is @@ -187,13 +266,13 @@ class TextWin(Win): if room and room.messages: self.separator_after = room.messages[-1] - def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False): + def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False, nick_size=10): """ Take one message, build it and add it to the list Return the number of lines that are built for the given message. """ - lines = self.build_message(message, timestamp=timestamp) + lines = self.build_message(message, timestamp=timestamp, nick_size=nick_size) if self.lock: self.lock_buffer.extend(lines) else: @@ -210,7 +289,7 @@ class TextWin(Win): self.built_lines.pop(0) return len(lines) - def build_message(self, message, timestamp=False): + def build_message(self, message, timestamp=False, nick_size=10): """ Build a list of lines from a message, without adding it to a list @@ -226,10 +305,13 @@ class TextWin(Win): else: default_color = None ret = [] - nick = truncate_nick(message.nickname) + nick = truncate_nick(message.nickname, nick_size) offset = 0 if message.ack: - offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1 + if message.ack > 0: + offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1 + else: + offset += poopt.wcswidth(get_theme().CHAR_NACK) + 1 if nick: offset += poopt.wcswidth(nick) + 2 # + nick + '> ' length if message.revisions > 0: @@ -268,12 +350,14 @@ class TextWin(Win): else: lines = self.built_lines[-self.height-self.pos:-self.pos] with_timestamps = config.get("show_timestamps") + nick_size = config.get("max_nick_length") self._win.move(0, 0) self._win.erase() for y, line in enumerate(lines): if line: msg = line.msg if line.start_pos == 0: + nick = truncate_nick(msg.nickname, nick_size) if msg.nick_color: color = msg.nick_color elif msg.user: @@ -283,18 +367,21 @@ class TextWin(Win): if with_timestamps: self.write_time(msg.str_time) if msg.ack: - self.write_ack() + if msg.ack > 0: + self.write_ack() + else: + self.write_nack() if msg.me: self._win.attron(to_curses_attr(get_theme().COLOR_ME_MESSAGE)) self.addstr('* ') - self.write_nickname(msg.nickname, color, msg.highlight) + self.write_nickname(nick, color, msg.highlight) if msg.revisions: self._win.attron(to_curses_attr(get_theme().COLOR_REVISIONS_MESSAGE)) self.addstr('%d' % msg.revisions) self._win.attrset(0) self.addstr(' ') else: - self.write_nickname(msg.nickname, color, msg.highlight) + self.write_nickname(nick, color, msg.highlight) if msg.revisions: self._win.attron(to_curses_attr(get_theme().COLOR_REVISIONS_MESSAGE)) self.addstr('%d' % msg.revisions) @@ -317,8 +404,7 @@ class TextWin(Win): # Offset for the nickname (if any) # plus a space and a > after it if line.msg.nickname: - offset += poopt.wcswidth( - truncate_nick(line.msg.nickname)) + offset += poopt.wcswidth(truncate_nick(line.msg.nickname, nick_size)) if line.msg.me: offset += 3 else: @@ -326,8 +412,11 @@ class TextWin(Win): offset += ceil(log10(line.msg.revisions + 1)) if line.msg.ack: - offset += 1 + poopt.wcswidth( - get_theme().CHAR_ACK_RECEIVED) + if msg.ack > 0: + offset += 1 + poopt.wcswidth( + get_theme().CHAR_ACK_RECEIVED) + else: + offset += 1 + poopt.wcswidth(get_theme().CHAR_NACK) self.write_text(y, offset, line.prepend+line.msg.txt[line.start_pos:line.end_pos]) @@ -343,12 +432,6 @@ class TextWin(Win): self.width, to_curses_attr(get_theme().COLOR_NEW_TEXT_SEPARATOR)) - def write_text(self, y, x, txt): - """ - write the text of a line. - """ - self.addstr_colored(txt, y, x) - def write_ack(self): color = get_theme().COLOR_CHAR_ACK self._win.attron(to_curses_attr(color)) @@ -356,6 +439,13 @@ class TextWin(Win): self._win.attroff(to_curses_attr(color)) self.addstr(' ') + def write_nack(self): + color = get_theme().COLOR_CHAR_NACK + self._win.attron(to_curses_attr(color)) + self.addstr(get_theme().CHAR_NACK) + self._win.attroff(to_curses_attr(color)) + self.addstr(' ') + def write_nickname(self, nickname, color, highlight=False): """ Write the nickname, using the user's color @@ -371,53 +461,19 @@ class TextWin(Win): color = hl_color if color: self._win.attron(to_curses_attr(color)) - self.addstr(truncate_nick(nickname)) + self.addstr(nickname) if color: self._win.attroff(to_curses_attr(color)) if highlight and hl_color == "reverse": self._win.attroff(curses.A_REVERSE) - def write_time(self, time): - """ - Write the date on the yth line of the window - """ - if time: - self.addstr(time) - self.addstr(' ') - - def resize(self, height, width, y, x, room=None): - if hasattr(self, 'width'): - old_width = self.width - else: - old_width = None - self._resize(height, width, y, x) - if room and self.width != old_width: - self.rebuild_everything(room) - - # reposition the scrolling after resize - # (see #2450) - buf_size = len(self.built_lines) - if buf_size - self.pos < self.height: - self.pos = buf_size - self.height - if self.pos < 0: - self.pos = 0 - - def rebuild_everything(self, room): - self.built_lines = [] - with_timestamps = config.get('show_timestamps') - for message in room.messages: - self.build_new_message(message, clean=False, timestamp=with_timestamps) - if self.separator_after is message: - self.build_new_message(None) - while len(self.built_lines) > self.lines_nb_limit: - self.built_lines.pop(0) - def modify_message(self, old_id, message): """ Find a message, and replace it with a new one (instead of rebuilding everything in order to correct a message) """ with_timestamps = config.get('show_timestamps') + nick_size = config.get('max_nick_length') for i in range(len(self.built_lines)-1, -1, -1): if self.built_lines[i] and self.built_lines[i].msg.identifier == old_id: index = i @@ -425,7 +481,7 @@ class TextWin(Win): self.built_lines.pop(index) index -= 1 index += 1 - lines = self.build_message(message, timestamp=with_timestamps) + lines = self.build_message(message, timestamp=with_timestamps, nick_size=nick_size) for line in lines: self.built_lines.insert(index, line) index += 1 @@ -435,3 +491,86 @@ class TextWin(Win): log.debug('** TextWin: deleting %s built lines', (len(self.built_lines))) del self.built_lines +class XMLTextWin(BaseTextWin): + def __init__(self): + BaseTextWin.__init__(self) + + def refresh(self): + log.debug('Refresh: %s', self.__class__.__name__) + theme = get_theme() + if self.height <= 0: + return + if self.pos == 0: + lines = self.built_lines[-self.height:] + else: + lines = self.built_lines[-self.height-self.pos:-self.pos] + self._win.move(0, 0) + self._win.erase() + for y, line in enumerate(lines): + if line: + msg = line.msg + if line.start_pos == 0: + if msg.nickname == theme.CHAR_XML_OUT: + color = theme.COLOR_XML_OUT + elif msg.nickname == theme.CHAR_XML_IN: + color = theme.COLOR_XML_IN + self.write_time(msg.str_time) + self.write_prefix(msg.nickname, color) + self.addstr(' ') + if y != self.height-1: + self.addstr('\n') + self._win.attrset(0) + for y, line in enumerate(lines): + offset = 0 + # Offset for the timestamp (if any) plus a space after it + offset += len(line.msg.str_time) + # space + offset += 1 + + # Offset for the prefix + offset += poopt.wcswidth(truncate_nick(line.msg.nickname)) + # space + offset += 1 + + self.write_text(y, offset, + line.prepend+line.msg.txt[line.start_pos:line.end_pos]) + if y != self.height-1: + self.addstr('\n') + self._win.attrset(0) + self._refresh() + + def build_message(self, message, timestamp=False, nick_size=10): + txt = message.txt + ret = [] + default_color = None + nick = truncate_nick(message.nickname, nick_size) + offset = 0 + if nick: + offset += poopt.wcswidth(nick) + 1 # + nick + ' ' length + if message.str_time: + offset += 1 + len(message.str_time) + if get_theme().CHAR_TIME_LEFT and message.str_time: + offset += 1 + if get_theme().CHAR_TIME_RIGHT and message.str_time: + offset += 1 + lines = poopt.cut_text(txt, self.width-offset-1) + prepend = default_color if default_color else '' + attrs = [] + for line in lines: + saved = Line(msg=message, start_pos=line[0], end_pos=line[1], prepend=prepend) + attrs = parse_attrs(message.txt[line[0]:line[1]], attrs) + if attrs: + prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs) + else: + if default_color: + prepend = default_color + else: + prepend = '' + ret.append(saved) + return ret + + def write_prefix(self, nickname, color): + self._win.attron(to_curses_attr(color)) + self.addstr(truncate_nick(nickname)) + self._win.attroff(to_curses_attr(color)) + diff --git a/src/xhtml.py b/src/xhtml.py index 01e2dfcd..b84ce943 100644 --- a/src/xhtml.py +++ b/src/xhtml.py @@ -183,6 +183,8 @@ whitespace_re = re.compile(r'\s+') xhtml_attr_re = re.compile(r'\x19-?\d[^}]*}|\x19[buaio]') xhtml_data_re = re.compile(r'data:image/([a-z]+);base64,(.+)') +poezio_color_double = re.compile(r'(?:\x19\d+}|\x19\d)+(\x19\d|\x19\d+})') +poezio_format_trim = re.compile(r'(\x19\d+}|\x19\d|\x19[buaio]|\x19o)+\x19o') xhtml_simple_attr_re = re.compile(r'\x19\d') @@ -303,7 +305,8 @@ class XHTMLHandler(sax.ContentHandler): @property def result(self): - return ''.join(self.builder).strip() + sanitized = re.sub(poezio_color_double, r'\1', ''.join(self.builder).strip()) + return re.sub(poezio_format_trim, '\x19o', sanitized) def append_formatting(self, formatting): self.formatting.append(formatting) |