Verified Commit 1cd0b4d6 authored by mathieui's avatar mathieui

Fix #2570 (add /filter_jid to XMLTab, and syntax highlighting)

Also add /filter_from and /filter_to, and allow chaining filters.
parent 93f05f04
......@@ -994,11 +994,11 @@ def command_message(self, args):
@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
@command_args_parser.quoted(1)
def command_adhoc(self, args):
......
......@@ -90,7 +90,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 = []
......
......@@ -17,7 +17,8 @@ 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
......@@ -37,6 +38,18 @@ 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)
except ImportError:
def highlight(text, *args, **kwargs):
return text
LEXER = None
FORMATTER = None
def on_session_start_features(self, _):
"""
Enable carbons & blocking on session start if wanted and possible
......@@ -1104,7 +1117,17 @@ 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)
xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER)
poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True)
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()
......@@ -1114,7 +1137,16 @@ 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)
xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER)
poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True)
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()
......
......@@ -13,23 +13,62 @@ log = logging.getLogger(__name__)
import curses
import os
from slixmpp.xmlstream import matcher
from slixmpp.xmlstream.handler import Callback
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: 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."))
self.register_command('clear', self.command_clear,
......@@ -42,8 +81,21 @@ class XMLTab(Tab):
shortdesc=_('Filter by id.'))
self.register_command('filter_xpath', self.command_filter_xpath,
usage='<xpath>',
desc=_('Show only the stanzas matching the xpath <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.'),
......@@ -64,6 +116,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.
......@@ -71,46 +151,66 @@ class XMLTab(Tab):
self.text_win.toggle_lock()
self.refresh()
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(mask),
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 = mask
self.update_filters(matcher.MatchXMLMask(mask))
self.refresh()
except:
self.core.information('Invalid XML Mask', 'Error')
self.command_reset('')
@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>"""
if args is None:
return self.core.command_help('filter_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 = args[0]
self.update_filters(matcher.MatcherId(args[0]))
self.refresh()
@command_args_parser.raw
def command_filter_xpath(self, xpath):
"""/filter_xpath <xpath>"""
try:
handler = Callback('custom matcher', matcher.MatchXPath(
xpath.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 = xpath
self.update_filters(matcher.MatchXPath(xpath.replace('%n', self.core.xmpp.default_ns)))
self.refresh()
except:
self.core.information('Invalid XML Path', 'Error')
......@@ -119,8 +219,11 @@ class XMLTab(Tab):
@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()
......@@ -130,8 +233,11 @@ class XMLTab(Tab):
"""/dump <filename>"""
if args is None:
return self.core.command_help('dump')
xml = self.core.xml_buffer.messages[:]
text = '\n'.join(('%s %s' % (msg.str_time, clean_text(msg.txt)) for msg in xml))
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:
......@@ -167,8 +273,12 @@ class XMLTab(Tab):
"""
/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()
......
......@@ -178,6 +178,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)
......
......@@ -15,5 +15,5 @@ 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
......@@ -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,113 @@ 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):
"""
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)
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):
"""
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')
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 __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 +224,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
......@@ -343,12 +421,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))
......@@ -377,41 +449,6 @@ class TextWin(Win):
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
......@@ -435,3 +472,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):