new XEP-0115 implementation (version 1.5)
This commit is contained in:
		
							parent
							
								
									be310652c4
								
							
						
					
					
						commit
						a3827fe5d0
					
				
					 13 changed files with 274 additions and 195 deletions
				
			
		| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
AC_INIT([Gajim - A Jabber Instant Messager],
 | 
			
		||||
		[0.11.4.3-svn],[http://trac.gajim.org/],[gajim])
 | 
			
		||||
		[0.11.4.4-svn],[http://trac.gajim.org/],[gajim])
 | 
			
		||||
AC_PREREQ([2.59])
 | 
			
		||||
AM_INIT_AUTOMAKE([1.8])
 | 
			
		||||
AC_CONFIG_HEADER(config.h)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,6 +20,7 @@ from itertools import *
 | 
			
		|||
import xmpp
 | 
			
		||||
import xmpp.features_nb
 | 
			
		||||
import gajim
 | 
			
		||||
import helpers
 | 
			
		||||
 | 
			
		||||
class CapsCache(object):
 | 
			
		||||
	''' This object keeps the mapping between caps data and real disco
 | 
			
		||||
| 
						 | 
				
			
			@ -93,17 +94,30 @@ class CapsCache(object):
 | 
			
		|||
 | 
			
		||||
		class CacheItem(object):
 | 
			
		||||
			''' TODO: logging data into db '''
 | 
			
		||||
			def __init__(ciself, node, version, ext=None):
 | 
			
		||||
			def __init__(ciself, hash_method, hash):
 | 
			
		||||
				# cached into db
 | 
			
		||||
				ciself.node = node
 | 
			
		||||
				ciself.version = version
 | 
			
		||||
				ciself.features = set()
 | 
			
		||||
				ciself.ext = ext
 | 
			
		||||
				ciself.exts = {}
 | 
			
		||||
				ciself.hash_method = hash_method
 | 
			
		||||
				ciself.hash = hash
 | 
			
		||||
 | 
			
		||||
				# set of tuples: (category, type, name)
 | 
			
		||||
				# (dictionaries are not hashable, so cannot be in sets)
 | 
			
		||||
				ciself.identities = set()
 | 
			
		||||
				@property
 | 
			
		||||
				def features():
 | 
			
		||||
					def fget(self):
 | 
			
		||||
						return self.getAttr('features')
 | 
			
		||||
					def fset(self, value):
 | 
			
		||||
						list_ = []
 | 
			
		||||
						for feature in value:
 | 
			
		||||
							list_.append(self.__names.setdefault(feature, feature))
 | 
			
		||||
						self.setAttr('features', list_)
 | 
			
		||||
 | 
			
		||||
				@property
 | 
			
		||||
				def identities():
 | 
			
		||||
					def fget(self):
 | 
			
		||||
						return self.getAttr('identities')
 | 
			
		||||
					def fset(self, value):
 | 
			
		||||
						list_ = []
 | 
			
		||||
						for identity in value:
 | 
			
		||||
							list_.append(self.__names.setdefault(identity, identity))
 | 
			
		||||
						self.setAttr('identities', list_)
 | 
			
		||||
 | 
			
		||||
				# not cached into db:
 | 
			
		||||
				# have we sent the query?
 | 
			
		||||
| 
						 | 
				
			
			@ -112,53 +126,21 @@ class CapsCache(object):
 | 
			
		|||
				# 2 == got the answer
 | 
			
		||||
				ciself.queried = 0
 | 
			
		||||
 | 
			
		||||
			class CacheQuery(object):
 | 
			
		||||
				def __init__(cqself, proxied):
 | 
			
		||||
					cqself.proxied=proxied
 | 
			
		||||
 | 
			
		||||
				def __getattr__(cqself, obj):
 | 
			
		||||
					if obj!='exts': return getattr(cqself.proxied[0], obj)
 | 
			
		||||
					return set(chain(ci.features for ci in cqself.proxied))
 | 
			
		||||
 | 
			
		||||
			def __getitem__(ciself, exts):
 | 
			
		||||
				if not exts:	# (), [], None, False, whatever
 | 
			
		||||
					return ciself
 | 
			
		||||
				if isinstance(exts, basestring):
 | 
			
		||||
					exts=(exts,)
 | 
			
		||||
				if len(exts)==1:
 | 
			
		||||
					ext=exts[0]
 | 
			
		||||
					if ext in ciself.exts:
 | 
			
		||||
						return ciself.exts[ext]
 | 
			
		||||
					x=CacheItem(ciself.node, ciself.version, ext)
 | 
			
		||||
					ciself.exts[ext]=x
 | 
			
		||||
					return x
 | 
			
		||||
				proxied = [ciself]
 | 
			
		||||
				proxied.extend(ciself[(e,)] for e in exts)
 | 
			
		||||
				return ciself.CacheQuery(proxied)
 | 
			
		||||
 | 
			
		||||
			def update(ciself, identities, features):
 | 
			
		||||
				# NOTE: self refers to CapsCache object, not to CacheItem
 | 
			
		||||
				self.identities=identities
 | 
			
		||||
				self.features=features
 | 
			
		||||
				self.logger.add_caps_entry(
 | 
			
		||||
					ciself.node, ciself.version, ciself.ext,
 | 
			
		||||
				ciself.identities=identities
 | 
			
		||||
				ciself.features=features
 | 
			
		||||
				self.logger.add_caps_entry(ciself.hash_method, ciself.hash,
 | 
			
		||||
					identities, features)
 | 
			
		||||
 | 
			
		||||
		self.__CacheItem = CacheItem
 | 
			
		||||
 | 
			
		||||
		# prepopulate data which we are sure of; note: we do not log these info
 | 
			
		||||
		gajimnode = 'http://gajim.org/caps'
 | 
			
		||||
 | 
			
		||||
		gajimcaps=self[(gajimnode, '0.11.1')]
 | 
			
		||||
		gajimcaps.category='client'
 | 
			
		||||
		gajimcaps.type='pc'
 | 
			
		||||
		gajimcaps.features=set((xmpp.NS_BYTESTREAM, xmpp.NS_SI,
 | 
			
		||||
			xmpp.NS_FILE, xmpp.NS_MUC, xmpp.NS_COMMANDS,
 | 
			
		||||
			xmpp.NS_DISCO_INFO, xmpp.NS_PING, xmpp.NS_TIME_REVISED))
 | 
			
		||||
		gajimcaps['cstates'].features=set((xmpp.NS_CHATSTATES,))
 | 
			
		||||
		gajimcaps['xhtml'].features=set((xmpp.NS_XHTML_IM,))
 | 
			
		||||
 | 
			
		||||
		# TODO: older gajim versions
 | 
			
		||||
		gajimcaps = self[('sha-1', gajim.caps_hash)]
 | 
			
		||||
		gajimcaps.identities = [gajim.gajim_identity]
 | 
			
		||||
		gajimcaps.features = gajim.gajim_common_features + \
 | 
			
		||||
			gajim.gajim_optional_features
 | 
			
		||||
 | 
			
		||||
		# start logging data from the net
 | 
			
		||||
		self.logger = logger
 | 
			
		||||
| 
						 | 
				
			
			@ -166,40 +148,32 @@ class CapsCache(object):
 | 
			
		|||
	def load_from_db(self):
 | 
			
		||||
		# get data from logger...
 | 
			
		||||
		if self.logger is not None:
 | 
			
		||||
			for node, ver, ext, identities, features in self.logger.iter_caps_data():
 | 
			
		||||
				x=self[(node, ver, ext)]
 | 
			
		||||
				x.identities=identities
 | 
			
		||||
				x.features=features
 | 
			
		||||
				x.queried=2
 | 
			
		||||
			for hash_method, hash, identities, features in \
 | 
			
		||||
			self.logger.iter_caps_data():
 | 
			
		||||
				x = self[(hash_method, hash)]
 | 
			
		||||
				x.identities = identities
 | 
			
		||||
				x.features = features
 | 
			
		||||
				x.queried = 2
 | 
			
		||||
 | 
			
		||||
	def __getitem__(self, caps):
 | 
			
		||||
		node_version = caps[:2]
 | 
			
		||||
		if node_version in self.__cache:
 | 
			
		||||
			return self.__cache[node_version][caps[2]]
 | 
			
		||||
		node, version = self.__names.setdefault(caps[0], caps[0]), caps[1]
 | 
			
		||||
		x=self.__CacheItem(node, version)
 | 
			
		||||
		self.__cache[(node, version)]=x
 | 
			
		||||
		if caps in self.__cache:
 | 
			
		||||
			return self.__cache[caps]
 | 
			
		||||
		hash_method, hash = caps[0], caps[1]
 | 
			
		||||
		x = self.__CacheItem(hash_method, hash)
 | 
			
		||||
		self.__cache[(hash_method, hash)] = x
 | 
			
		||||
		return x
 | 
			
		||||
 | 
			
		||||
	def preload(self, account, jid, node, ver, exts):
 | 
			
		||||
	def preload(self, con, jid, node, hash_method, hash):
 | 
			
		||||
		''' Preload data about (node, ver, exts) caps using disco
 | 
			
		||||
		query to jid using proper connection. Don't query if
 | 
			
		||||
		the data is already in cache. '''
 | 
			
		||||
		q=self[(node, ver, ())]
 | 
			
		||||
		qq=q
 | 
			
		||||
		q = self[(hash_method, hash)]
 | 
			
		||||
 | 
			
		||||
		if q.queried==0:
 | 
			
		||||
			# do query for bare node+version pair
 | 
			
		||||
			# do query for bare node+hash pair
 | 
			
		||||
			# this will create proper object
 | 
			
		||||
			q.queried=1
 | 
			
		||||
			account.discoverInfo(jid, '%s#%s' % (node, ver))
 | 
			
		||||
 | 
			
		||||
		for ext in exts:
 | 
			
		||||
			qq=q[ext]
 | 
			
		||||
			if qq.queried==0:
 | 
			
		||||
				# do query for node+version+ext triple
 | 
			
		||||
				qq.queried=1
 | 
			
		||||
				account.discoverInfo(jid, '%s#%s' % (node, ext))
 | 
			
		||||
			con.discoverInfo(jid, '%s#%s' % (node, hash))
 | 
			
		||||
 | 
			
		||||
gajim.capscache = CapsCache(gajim.logger)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -210,19 +184,14 @@ class ConnectionCaps(object):
 | 
			
		|||
		for xmpp registered in connection_handlers.py'''
 | 
			
		||||
 | 
			
		||||
		# get the caps element
 | 
			
		||||
		caps=presence.getTag('c')
 | 
			
		||||
		if not caps: return
 | 
			
		||||
 | 
			
		||||
		node, ver=caps['node'], caps['ver']
 | 
			
		||||
		if node is None or ver is None:
 | 
			
		||||
			# improper caps in stanza, ignoring
 | 
			
		||||
		caps = presence.getTag('c')
 | 
			
		||||
		if not caps:
 | 
			
		||||
			return
 | 
			
		||||
 | 
			
		||||
		try:
 | 
			
		||||
			exts=caps['ext'].split(' ')
 | 
			
		||||
		except AttributeError:
 | 
			
		||||
			# no exts means no exts, a perfectly valid case
 | 
			
		||||
			exts=[]
 | 
			
		||||
		hash_method, node, hash = caps['hash'], caps['node'], caps['ver']
 | 
			
		||||
		if hash_method is None or node is None or hash is None:
 | 
			
		||||
			# improper caps in stanza, ignoring
 | 
			
		||||
			return
 | 
			
		||||
 | 
			
		||||
		# we will put these into proper Contact object and ask
 | 
			
		||||
		# for disco... so that disco will learn how to interpret
 | 
			
		||||
| 
						 | 
				
			
			@ -231,7 +200,7 @@ class ConnectionCaps(object):
 | 
			
		|||
		jid=str(presence.getFrom())
 | 
			
		||||
 | 
			
		||||
		# start disco query...
 | 
			
		||||
		gajim.capscache.preload(self, jid, node, ver, exts)
 | 
			
		||||
		gajim.capscache.preload(self, jid, node, hash_method, hash)
 | 
			
		||||
 | 
			
		||||
		contact=gajim.contacts.get_contact_from_full_jid(self.name, jid)
 | 
			
		||||
		if contact in [None, []]:
 | 
			
		||||
| 
						 | 
				
			
			@ -240,25 +209,31 @@ class ConnectionCaps(object):
 | 
			
		|||
			contact = contact[0]
 | 
			
		||||
 | 
			
		||||
		# overwriting old data
 | 
			
		||||
		contact.caps_node=node
 | 
			
		||||
		contact.caps_ver=ver
 | 
			
		||||
		contact.caps_exts=exts
 | 
			
		||||
		contact.caps_node = node
 | 
			
		||||
		contact.caps_hash_method = hash_method
 | 
			
		||||
		contact.caps_hash = hash
 | 
			
		||||
 | 
			
		||||
	def _capsDiscoCB(self, jid, node, identities, features, data):
 | 
			
		||||
		contact=gajim.contacts.get_contact_from_full_jid(self.name, jid)
 | 
			
		||||
		if not contact: return
 | 
			
		||||
		if not contact.caps_node: return # we didn't asked for that?
 | 
			
		||||
		if not node.startswith(contact.caps_node+'#'): return
 | 
			
		||||
		node, ext = node.split('#', 1)
 | 
			
		||||
		if ext==contact.caps_ver:	# this can be also version (like '0.9')
 | 
			
		||||
			exts=None
 | 
			
		||||
		else:
 | 
			
		||||
			exts=(ext,)
 | 
			
		||||
		contact = gajim.contacts.get_contact_from_full_jid(self.name, jid)
 | 
			
		||||
		if not contact:
 | 
			
		||||
			return
 | 
			
		||||
		if not contact.caps_node:
 | 
			
		||||
			return # we didn't asked for that?
 | 
			
		||||
		if not node.startswith(contact.caps_node + '#'):
 | 
			
		||||
			return
 | 
			
		||||
		node, hash = node.split('#', 1)
 | 
			
		||||
		computed_hash = helpers.compute_caps_hash(identities, features,
 | 
			
		||||
			contact.caps_hash_method)
 | 
			
		||||
		if computed_hash != hash:
 | 
			
		||||
			# wrong hash, forget it
 | 
			
		||||
			contact.caps_node = ''
 | 
			
		||||
			contact.caps_hash_method = ''
 | 
			
		||||
			contact.caps_hash = ''
 | 
			
		||||
			return
 | 
			
		||||
 | 
			
		||||
		# if we don't have this info already...
 | 
			
		||||
		caps=gajim.capscache[(node, contact.caps_ver, exts)]
 | 
			
		||||
		if caps.queried==2: return
 | 
			
		||||
		caps = gajim.capscache[(contact.caps_hash_method, hash)]
 | 
			
		||||
		if caps.queried == 2:
 | 
			
		||||
			return
 | 
			
		||||
 | 
			
		||||
		identities=set((i['category'], i['type'], i.get('name')) for i in identities)
 | 
			
		||||
		caps.update(identities, features)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -82,9 +82,8 @@ def create_log_db():
 | 
			
		|||
		CREATE INDEX idx_logs_jid_id_kind ON logs (jid_id, kind);
 | 
			
		||||
 | 
			
		||||
		CREATE TABLE caps_cache (
 | 
			
		||||
			node TEXT,
 | 
			
		||||
			ver TEXT,
 | 
			
		||||
			ext TEXT,
 | 
			
		||||
			hash_method TEXT,
 | 
			
		||||
			hash TEXT,
 | 
			
		||||
			data BLOB);
 | 
			
		||||
 | 
			
		||||
		CREATE TABLE IF NOT EXISTS rooms_last_message_time(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -667,7 +667,7 @@ class ConnectionDisco:
 | 
			
		|||
			common.xmpp.NS_DISCO, frm = to)
 | 
			
		||||
		iq.setAttr('id', id)
 | 
			
		||||
		query = iq.setTag('query')
 | 
			
		||||
		query.setAttr('node','http://gajim.org/caps#' + gajim.version.split('-',
 | 
			
		||||
		query.setAttr('node','http://gajim.org#' + gajim.version.split('-',
 | 
			
		||||
			1)[0])
 | 
			
		||||
		for f in (common.xmpp.NS_BYTESTREAM, common.xmpp.NS_SI,
 | 
			
		||||
		common.xmpp.NS_FILE, common.xmpp.NS_COMMANDS):
 | 
			
		||||
| 
						 | 
				
			
			@ -744,55 +744,17 @@ class ConnectionDisco:
 | 
			
		|||
			q = iq.getTag('query')
 | 
			
		||||
			if node:
 | 
			
		||||
				q.setAttr('node', node)
 | 
			
		||||
			q.addChild('identity', attrs = {'type': 'pc', 'category': 'client',
 | 
			
		||||
				'name': 'Gajim'})
 | 
			
		||||
			q.addChild('identity', attrs = gajim.gajim_identity)
 | 
			
		||||
			extension = None
 | 
			
		||||
			if node and node.find('#') != -1:
 | 
			
		||||
				extension = node[node.index('#') + 1:]
 | 
			
		||||
			client_version = 'http://gajim.org/caps#' + gajim.version.split('-',
 | 
			
		||||
				1)[0]
 | 
			
		||||
			client_version = 'http://gajim.org#' + gajim.caps_hash
 | 
			
		||||
 | 
			
		||||
			if node in (None, client_version):
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_BYTESTREAM})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_SI})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_FILE})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_MUC})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_MUC_USER})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_MUC_ADMIN})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_MUC_OWNER})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_MUC_CONFIG})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_COMMANDS})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_DISCO_INFO})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': 'ipv6'})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': 'jabber:iq:gateway'})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_LAST})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_PRIVACY})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_PRIVATE})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_REGISTER})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_VERSION})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_DATA})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_ENCRYPTED})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': 'msglog'})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': 'sslc2s'})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': 'stringprep'})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_PING})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_ACTIVITY})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_ACTIVITY + '+notify'})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_TUNE})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_TUNE + '+notify'})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_MOOD})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_MOOD + '+notify'})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_ESESSION_INIT})
 | 
			
		||||
 | 
			
		||||
			if (node is None or extension == 'cstates') and gajim.config.get('outgoing_chat_state_notifactions') != 'disabled':
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_CHATSTATES})
 | 
			
		||||
 | 
			
		||||
			if (node is None or extension == 'xhtml') and not gajim.config.get('ignore_incoming_xhtml'):
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_XHTML_IM})
 | 
			
		||||
 | 
			
		||||
			if node is None:
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_PING})
 | 
			
		||||
				q.addChild('feature', attrs = {'var': common.xmpp.NS_TIME_REVISED})
 | 
			
		||||
				for f in gajim.gajim_common_features:
 | 
			
		||||
					q.addChild('feature', attrs = {'var': f})
 | 
			
		||||
				for f in gajim.gajim_optional_features:
 | 
			
		||||
					q.addChild('feature', attrs = {'var': f})
 | 
			
		||||
 | 
			
		||||
			if q.getChildren():
 | 
			
		||||
				self.connection.send(iq)
 | 
			
		||||
| 
						 | 
				
			
			@ -892,16 +854,9 @@ class ConnectionVcard:
 | 
			
		|||
	def add_caps(self, p):
 | 
			
		||||
		''' advertise our capabilities in presence stanza (xep-0115)'''
 | 
			
		||||
		c = p.setTag('c', namespace = common.xmpp.NS_CAPS)
 | 
			
		||||
		c.setAttr('node', 'http://gajim.org/caps')
 | 
			
		||||
		ext = []
 | 
			
		||||
		if not gajim.config.get('ignore_incoming_xhtml'):
 | 
			
		||||
			ext.append('xhtml')
 | 
			
		||||
		if gajim.config.get('outgoing_chat_state_notifactions') != 'disabled':
 | 
			
		||||
			ext.append('cstates')
 | 
			
		||||
 | 
			
		||||
		if len(ext):
 | 
			
		||||
			c.setAttr('ext', ' '.join(ext))
 | 
			
		||||
		c.setAttr('ver', gajim.version.split('-', 1)[0])
 | 
			
		||||
		c.setAttr('hash', 'sha-1')
 | 
			
		||||
		c.setAttr('node', 'http://gajim.org')
 | 
			
		||||
		c.setAttr('ver', gajim.caps_hash)
 | 
			
		||||
		return p
 | 
			
		||||
	
 | 
			
		||||
	def node_to_dict(self, node):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,8 +44,8 @@ class Contact:
 | 
			
		|||
		# Capabilities; filled by caps.py/ConnectionCaps object
 | 
			
		||||
		# every time it gets these from presence stanzas
 | 
			
		||||
		self.caps_node = None
 | 
			
		||||
		self.caps_ver = None
 | 
			
		||||
		self.caps_exts = None
 | 
			
		||||
		self.caps_hash_method = None
 | 
			
		||||
		self.caps_hash = None
 | 
			
		||||
 | 
			
		||||
		# please read xep-85 http://www.xmpp.org/extensions/xep-0085.html
 | 
			
		||||
		# we keep track of xep85 support with the peer by three extra states:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ docdir = '../'
 | 
			
		|||
 | 
			
		||||
datadir = '../'
 | 
			
		||||
 | 
			
		||||
version = '0.11.4.3-svn'
 | 
			
		||||
version = '0.11.4.4-svn'
 | 
			
		||||
 | 
			
		||||
import sys, os.path
 | 
			
		||||
for base in ('.', 'common'):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,6 +27,7 @@ import locale
 | 
			
		|||
import config
 | 
			
		||||
from contacts import Contacts
 | 
			
		||||
from events import Events
 | 
			
		||||
import xmpp
 | 
			
		||||
 | 
			
		||||
try:
 | 
			
		||||
	import defs
 | 
			
		||||
| 
						 | 
				
			
			@ -164,6 +165,21 @@ else:
 | 
			
		|||
	if system('gpg -h >/dev/null 2>&1'):
 | 
			
		||||
		HAVE_GPG = False
 | 
			
		||||
 | 
			
		||||
gajim_identity = {'type': 'pc', 'category': 'client', 'name': 'Gajim'}
 | 
			
		||||
gajim_common_features = [xmpp.NS_BYTESTREAM, xmpp.NS_SI,
 | 
			
		||||
	xmpp.NS_FILE, xmpp.NS_MUC, xmpp.NS_MUC_USER,
 | 
			
		||||
	xmpp.NS_MUC_ADMIN, xmpp.NS_MUC_OWNER,
 | 
			
		||||
	xmpp.NS_MUC_CONFIG, xmpp.NS_COMMANDS,
 | 
			
		||||
	xmpp.NS_DISCO_INFO, 'ipv6', 'jabber:iq:gateway', xmpp.NS_LAST,
 | 
			
		||||
	xmpp.NS_PRIVACY, xmpp.NS_PRIVATE, xmpp.NS_REGISTER,
 | 
			
		||||
	xmpp.NS_VERSION, xmpp.NS_DATA, xmpp.NS_ENCRYPTED,
 | 
			
		||||
	'msglog', 'sslc2s', 'stringprep', xmpp.NS_PING,
 | 
			
		||||
	xmpp.NS_TIME_REVISED]
 | 
			
		||||
# Optional features gajim supports
 | 
			
		||||
gajim_optional_features = []
 | 
			
		||||
 | 
			
		||||
caps_hash = ''
 | 
			
		||||
 | 
			
		||||
def get_nick_from_jid(jid):
 | 
			
		||||
	pos = jid.find('@')
 | 
			
		||||
	return jid[:pos]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,6 +30,8 @@ import urllib
 | 
			
		|||
import errno
 | 
			
		||||
import select
 | 
			
		||||
import sha
 | 
			
		||||
import hashlib
 | 
			
		||||
import base64
 | 
			
		||||
import sys
 | 
			
		||||
from encodings.punycode import punycode_encode
 | 
			
		||||
from encodings import idna
 | 
			
		||||
| 
						 | 
				
			
			@ -38,7 +40,7 @@ import gajim
 | 
			
		|||
from i18n import Q_
 | 
			
		||||
from i18n import ngettext
 | 
			
		||||
from xmpp_stringprep import nodeprep, resourceprep, nameprep
 | 
			
		||||
 | 
			
		||||
import xmpp
 | 
			
		||||
 | 
			
		||||
if sys.platform == 'darwin':
 | 
			
		||||
	from osx import nsapp
 | 
			
		||||
| 
						 | 
				
			
			@ -1199,3 +1201,93 @@ def prepare_and_validate_gpg_keyID(account, jid, keyID):
 | 
			
		|||
			keyID = 'UNKNOWN'
 | 
			
		||||
	return keyID
 | 
			
		||||
 | 
			
		||||
def sort_identities_func(i1, i2):
 | 
			
		||||
	cat1 = i1['category']
 | 
			
		||||
	cat2 = i2['category']
 | 
			
		||||
	if cat1 < cat2:
 | 
			
		||||
		return -1
 | 
			
		||||
	if cat1 > cat2:
 | 
			
		||||
		return 1
 | 
			
		||||
	if i1.has_key('type'):
 | 
			
		||||
		type1 = i1['type']
 | 
			
		||||
	else:
 | 
			
		||||
		type1 = ''
 | 
			
		||||
	if i2.has_key('type'):
 | 
			
		||||
		type2 = i2['type']
 | 
			
		||||
	else:
 | 
			
		||||
		type2 = ''
 | 
			
		||||
	if type1 < type2:
 | 
			
		||||
		return -1
 | 
			
		||||
	if type1 > type2:
 | 
			
		||||
		return 1
 | 
			
		||||
	if i1.has_key('xml:lang'):
 | 
			
		||||
		lang1 = i1['xml:lang']
 | 
			
		||||
	else:
 | 
			
		||||
		lang1 = ''
 | 
			
		||||
	if i2.has_key('xml:lang'):
 | 
			
		||||
		lang2 = i2['xml:lang']
 | 
			
		||||
	else:
 | 
			
		||||
		lang2 = ''
 | 
			
		||||
	if lang1 < lang2:
 | 
			
		||||
		return -1
 | 
			
		||||
	if lang1 > lang2:
 | 
			
		||||
		return 1
 | 
			
		||||
	return 0
 | 
			
		||||
 | 
			
		||||
def compute_caps_hash(identities, features, hash_method='sha-1'):
 | 
			
		||||
	S = ''
 | 
			
		||||
	identities.sort(cmp=sort_identities_func)
 | 
			
		||||
	for i in identities:
 | 
			
		||||
		c = i['category']
 | 
			
		||||
		if i.has_key('type'):
 | 
			
		||||
			type_ = i['type']
 | 
			
		||||
		else:
 | 
			
		||||
			type_ = ''
 | 
			
		||||
		if i.has_key('xml:lang'):
 | 
			
		||||
			lang = i['xml:lang']
 | 
			
		||||
		else:
 | 
			
		||||
			lang = ''
 | 
			
		||||
		if i.has_key('name'):
 | 
			
		||||
			name = i['name']
 | 
			
		||||
		else:
 | 
			
		||||
			name = ''
 | 
			
		||||
		S += '%s/%s/%s/%s<' % (c, type_, lang, name)
 | 
			
		||||
	features.sort()
 | 
			
		||||
	for f in features:
 | 
			
		||||
		S += '%s<' % f
 | 
			
		||||
	if hash_method == 'sha-1':
 | 
			
		||||
		hash = hashlib.sha1(S)
 | 
			
		||||
	elif hash_method == 'md5':
 | 
			
		||||
		hash = hashlib.md5(S)
 | 
			
		||||
	else:
 | 
			
		||||
		return ''
 | 
			
		||||
	return base64.b64encode(hash.digest())
 | 
			
		||||
 | 
			
		||||
def update_optional_features():
 | 
			
		||||
	gajim.gajim_optional_features = []
 | 
			
		||||
	if gajim.config.get('publish_mood'):
 | 
			
		||||
		gajim.gajim_optional_features.append(xmpp.NS_MOOD)
 | 
			
		||||
	if gajim.config.get('subscribe_mood'):
 | 
			
		||||
		gajim.gajim_optional_features.append(xmpp.NS_MOOD + '+notify')
 | 
			
		||||
	if gajim.config.get('publish_activity'):
 | 
			
		||||
		gajim.gajim_optional_features.append(xmpp.NS_ACTIVITY)
 | 
			
		||||
	if gajim.config.get('subscribe_activity'):
 | 
			
		||||
		gajim.gajim_optional_features.append(xmpp.NS_ACTIVITY + '+notify')
 | 
			
		||||
	if gajim.config.get('publish_tune'):
 | 
			
		||||
		gajim.gajim_optional_features.append(xmpp.NS_TUNE)
 | 
			
		||||
	if gajim.config.get('subscribe_tune'):
 | 
			
		||||
		gajim.gajim_optional_features.append(xmpp.NS_TUNE + '+notify')
 | 
			
		||||
	if gajim.config.get('outgoing_chat_state_notifactions') != 'disabled':
 | 
			
		||||
		gajim.gajim_optional_features.append(xmpp.NS_CHATSTATES)
 | 
			
		||||
	if not gajim.config.get('ignore_incoming_xhtml'):
 | 
			
		||||
		gajim.gajim_optional_features.append(xmpp.NS_XHTML_IM)
 | 
			
		||||
	if gajim.HAVE_PYCRYPTO:
 | 
			
		||||
		gajim.gajim_optional_features.append(xmpp.NS_ESESSION_INIT)
 | 
			
		||||
	gajim.caps_hash = compute_caps_hash([gajim.gajim_identity],
 | 
			
		||||
		gajim.gajim_common_features + gajim.gajim_optional_features)
 | 
			
		||||
	# re-send presence with new hash
 | 
			
		||||
	for account in gajim.connections:
 | 
			
		||||
		connected = gajim.connections[account].connected
 | 
			
		||||
		if connected > 1 and gajim.SHOW_LIST[connected] != 'invisible':
 | 
			
		||||
			gajim.connections[account].change_status(gajim.SHOW_LIST[connected],
 | 
			
		||||
				gajim.connections[account].status)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -698,60 +698,59 @@ class Logger:
 | 
			
		|||
		# to get that data without trying to convert it to unicode
 | 
			
		||||
		#tmp, self.con.text_factory = self.con.text_factory, str
 | 
			
		||||
		try:
 | 
			
		||||
			self.cur.execute('''SELECT node, ver, ext, data FROM caps_cache;''');
 | 
			
		||||
			self.cur.execute('SELECT hash_method, hash, data FROM caps_cache;');
 | 
			
		||||
		except sqlite.OperationalError:
 | 
			
		||||
			# might happen when there's no caps_cache table yet
 | 
			
		||||
			# -- there's no data to read anyway then
 | 
			
		||||
			#self.con.text_factory = tmp
 | 
			
		||||
			return
 | 
			
		||||
		#self.con.text_factory = tmp
 | 
			
		||||
 | 
			
		||||
		for node, ver, ext, data in self.cur:
 | 
			
		||||
		for hash_method, hash, data in self.cur:
 | 
			
		||||
			# for each row: unpack the data field
 | 
			
		||||
			# (format: (category, type, name, category, type, name, ...
 | 
			
		||||
			#   ..., 'FEAT', feature1, feature2, ...).join(' '))
 | 
			
		||||
			# NOTE: if there's a need to do more gzip, put that to a function
 | 
			
		||||
			data=GzipFile(fileobj=StringIO(str(data))).read().split('\0')
 | 
			
		||||
			data = GzipFile(fileobj=StringIO(str(data))).read().split('\0')
 | 
			
		||||
			i=0
 | 
			
		||||
			identities=set()
 | 
			
		||||
			features=set()
 | 
			
		||||
			while i<(len(data)-2) and data[i]!='FEAT':
 | 
			
		||||
				category=data[i]
 | 
			
		||||
				type=data[i+1]
 | 
			
		||||
				name=data[i+2]
 | 
			
		||||
				identities.add((category,type,name))
 | 
			
		||||
				i+=3
 | 
			
		||||
			identities = list()
 | 
			
		||||
			features = list()
 | 
			
		||||
			while i < (len(data) - 3) and data[i] != 'FEAT':
 | 
			
		||||
				category = data[i]
 | 
			
		||||
				type_ = data[i + 1]
 | 
			
		||||
				lang = data[i + 2]
 | 
			
		||||
				name = data[i + 3]
 | 
			
		||||
				identities.append({'category': category, 'type': type_,
 | 
			
		||||
					'xml:lang': lang, 'name': name})
 | 
			
		||||
				i += 4
 | 
			
		||||
			i+=1
 | 
			
		||||
			while i<len(data):
 | 
			
		||||
				features.add(data[i])
 | 
			
		||||
				i+=1
 | 
			
		||||
			if not ext: ext=None	# to make '' a None
 | 
			
		||||
			while i < len(data):
 | 
			
		||||
				features.append(data[i])
 | 
			
		||||
				i += 1
 | 
			
		||||
 | 
			
		||||
			# yield the row
 | 
			
		||||
			yield node, ver, ext, identities, features
 | 
			
		||||
			yield hash_method, hash, identities, features
 | 
			
		||||
 | 
			
		||||
	def add_caps_entry(self, node, ver, ext, identities, features):
 | 
			
		||||
	def add_caps_entry(self, hash_method, hash, identities, features):
 | 
			
		||||
		data=[]
 | 
			
		||||
		for identity in identities:
 | 
			
		||||
			# there is no FEAT category
 | 
			
		||||
			if identity[0]=='FEAT': return
 | 
			
		||||
			if len(identity)<2 or not identity[2]:
 | 
			
		||||
				data.extend((identity[0], identity[1], ''))
 | 
			
		||||
			else:
 | 
			
		||||
				data.extend(identity)
 | 
			
		||||
			if identity['category'] == 'FEAT':
 | 
			
		||||
				return
 | 
			
		||||
			data.extend((identity.get('category'), identity.get('type', ''),
 | 
			
		||||
				identity.get('xml:lang', ''), identity.get('name', '')))
 | 
			
		||||
		data.append('FEAT')
 | 
			
		||||
		data.extend(features)
 | 
			
		||||
		data = '\0'.join(data)
 | 
			
		||||
		# if there's a need to do more gzip, put that to a function
 | 
			
		||||
		string = StringIO()
 | 
			
		||||
		gzip=GzipFile(fileobj=string, mode='w')
 | 
			
		||||
		gzip = GzipFile(fileobj=string, mode='w')
 | 
			
		||||
		gzip.write(data)
 | 
			
		||||
		gzip.close()
 | 
			
		||||
		data = string.getvalue()
 | 
			
		||||
		self.cur.execute('''
 | 
			
		||||
			INSERT INTO caps_cache ( node, ver, ext, data )
 | 
			
		||||
			VALUES (?, ?, ?, ?);
 | 
			
		||||
			''', (node, ver, ext, buffer(data))) # (1) -- note above
 | 
			
		||||
			INSERT INTO caps_cache ( hash_method, hash, data )
 | 
			
		||||
			VALUES (?, ?, ?);
 | 
			
		||||
			''', (hash_method, hash, buffer(data))) # (1) -- note above
 | 
			
		||||
		try:
 | 
			
		||||
			self.con.commit()
 | 
			
		||||
		except sqlite.OperationalError, e:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -180,6 +180,8 @@ class OptionsParser:
 | 
			
		|||
			self.update_config_to_01142()
 | 
			
		||||
		if old < [0, 11, 4, 3] and new >= [0, 11, 4, 3]:
 | 
			
		||||
			self.update_config_to_01143()
 | 
			
		||||
		if old < [0, 11, 4, 4] and new >= [0, 11, 4, 4]:
 | 
			
		||||
			self.update_config_to_01144()
 | 
			
		||||
 | 
			
		||||
		gajim.logger.init_vars()
 | 
			
		||||
		gajim.config.set('version', new_version)
 | 
			
		||||
| 
						 | 
				
			
			@ -566,3 +568,30 @@ class OptionsParser:
 | 
			
		|||
			pass
 | 
			
		||||
		con.close()
 | 
			
		||||
		gajim.config.set('version', '0.11.4.3')
 | 
			
		||||
 | 
			
		||||
	def update_config_to_01144(self):
 | 
			
		||||
		back = os.getcwd()
 | 
			
		||||
		os.chdir(logger.LOG_DB_FOLDER)
 | 
			
		||||
		con = sqlite.connect(logger.LOG_DB_FILE)
 | 
			
		||||
		os.chdir(back)
 | 
			
		||||
		cur = con.cursor()
 | 
			
		||||
		try:
 | 
			
		||||
			cur.executescript('DROP TABLE caps_cache;')
 | 
			
		||||
			con.commit()
 | 
			
		||||
		except sqlite.OperationalError, e:
 | 
			
		||||
			pass
 | 
			
		||||
		try:
 | 
			
		||||
			cur.executescript(
 | 
			
		||||
				'''
 | 
			
		||||
				CREATE TABLE caps_cache (
 | 
			
		||||
					hash_method TEXT,
 | 
			
		||||
					hash TEXT,
 | 
			
		||||
					data BLOB
 | 
			
		||||
				);
 | 
			
		||||
				'''
 | 
			
		||||
			)
 | 
			
		||||
			con.commit()
 | 
			
		||||
		except sqlite.OperationalError, e:
 | 
			
		||||
			pass
 | 
			
		||||
		con.close()
 | 
			
		||||
		gajim.config.set('version', '0.11.4.4')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -33,7 +33,7 @@ import thread
 | 
			
		|||
import logging
 | 
			
		||||
log = logging.getLogger('gajim.c.x.transports_nb')
 | 
			
		||||
 | 
			
		||||
from common import gajim
 | 
			
		||||
import common.gajim
 | 
			
		||||
 | 
			
		||||
USE_PYOPENSSL = False
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -755,16 +755,16 @@ class NonBlockingTLS(PlugIn):
 | 
			
		|||
		#tcpsock._sslContext = OpenSSL.SSL.Context(OpenSSL.SSL.SSLv23_METHOD)
 | 
			
		||||
		tcpsock.ssl_errnum = 0
 | 
			
		||||
		tcpsock._sslContext.set_verify(OpenSSL.SSL.VERIFY_PEER, self._ssl_verify_callback)
 | 
			
		||||
		cacerts = os.path.join(gajim.DATA_DIR, 'other', 'cacerts.pem')
 | 
			
		||||
		cacerts = os.path.join(common.gajim.DATA_DIR, 'other', 'cacerts.pem')
 | 
			
		||||
		try:
 | 
			
		||||
			tcpsock._sslContext.load_verify_locations(cacerts)
 | 
			
		||||
		except:
 | 
			
		||||
			log.warning('Unable to load SSL certificats from file %s' % \
 | 
			
		||||
				os.path.abspath(cacerts))
 | 
			
		||||
		# load users certs
 | 
			
		||||
		if os.path.isfile(gajim.MY_CACERTS):
 | 
			
		||||
		if os.path.isfile(common.gajim.MY_CACERTS):
 | 
			
		||||
			store = tcpsock._sslContext.get_cert_store()
 | 
			
		||||
			f = open(gajim.MY_CACERTS)
 | 
			
		||||
			f = open(common.gajim.MY_CACERTS)
 | 
			
		||||
			lines = f.readlines()
 | 
			
		||||
			i = 0
 | 
			
		||||
			begin = -1
 | 
			
		||||
| 
						 | 
				
			
			@ -779,11 +779,11 @@ class NonBlockingTLS(PlugIn):
 | 
			
		|||
						store.add_cert(X509cert)
 | 
			
		||||
					except OpenSSL.crypto.Error, exception_obj:
 | 
			
		||||
						log.warning('Unable to load a certificate from file %s: %s' %\
 | 
			
		||||
							(gajim.MY_CACERTS, exception_obj.args[0][0][2]))
 | 
			
		||||
							(common.gajim.MY_CACERTS, exception_obj.args[0][0][2]))
 | 
			
		||||
					except:
 | 
			
		||||
						log.warning(
 | 
			
		||||
							'Unknown error while loading certificate from file %s' % \
 | 
			
		||||
							gajim.MY_CACERTS)
 | 
			
		||||
							common.gajim.MY_CACERTS)
 | 
			
		||||
					begin = -1
 | 
			
		||||
				i += 1
 | 
			
		||||
		tcpsock._sslObj = OpenSSL.SSL.Connection(tcpsock._sslContext, tcpsock._sock)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -538,6 +538,7 @@ class PreferencesWindow:
 | 
			
		|||
				if gajim.connections[account].pep_supported:
 | 
			
		||||
					pep.user_send_mood(account, '')
 | 
			
		||||
		self.on_checkbutton_toggled(widget, 'publish_mood')
 | 
			
		||||
		helpers.update_optional_features()
 | 
			
		||||
 | 
			
		||||
	def on_publish_activity_checkbutton_toggled(self, widget):
 | 
			
		||||
		if widget.get_active() == False:
 | 
			
		||||
| 
						 | 
				
			
			@ -545,6 +546,7 @@ class PreferencesWindow:
 | 
			
		|||
				if gajim.connections[account].pep_supported:
 | 
			
		||||
					pep.user_send_activity(account, '')
 | 
			
		||||
		self.on_checkbutton_toggled(widget, 'publish_activity')
 | 
			
		||||
		helpers.update_optional_features()
 | 
			
		||||
 | 
			
		||||
	def on_publish_tune_checkbutton_toggled(self, widget):
 | 
			
		||||
		if widget.get_active() == False:
 | 
			
		||||
| 
						 | 
				
			
			@ -552,17 +554,21 @@ class PreferencesWindow:
 | 
			
		|||
				if gajim.connections[account].pep_supported:
 | 
			
		||||
					pep.user_send_tune(account, '')
 | 
			
		||||
		self.on_checkbutton_toggled(widget, 'publish_tune')
 | 
			
		||||
		helpers.update_optional_features()
 | 
			
		||||
		gajim.interface.roster.enable_syncing_status_msg_from_current_music_track(
 | 
			
		||||
			widget.get_active())
 | 
			
		||||
 | 
			
		||||
	def on_subscribe_mood_checkbutton_toggled(self, widget):
 | 
			
		||||
		self.on_checkbutton_toggled(widget, 'subscribe_mood')
 | 
			
		||||
		helpers.update_optional_features()
 | 
			
		||||
 | 
			
		||||
	def on_subscribe_activity_checkbutton_toggled(self, widget):
 | 
			
		||||
		self.on_checkbutton_toggled(widget, 'subscribe_activity')
 | 
			
		||||
		helpers.update_optional_features()
 | 
			
		||||
 | 
			
		||||
	def on_subscribe_tune_checkbutton_toggled(self, widget):
 | 
			
		||||
		self.on_checkbutton_toggled(widget, 'subscribe_tune')
 | 
			
		||||
		helpers.update_optional_features()
 | 
			
		||||
 | 
			
		||||
	def on_sort_by_show_checkbutton_toggled(self, widget):
 | 
			
		||||
		self.on_checkbutton_toggled(widget, 'sort_by_show')
 | 
			
		||||
| 
						 | 
				
			
			@ -625,6 +631,7 @@ class PreferencesWindow:
 | 
			
		|||
 | 
			
		||||
	def on_xhtml_checkbutton_toggled(self, widget):
 | 
			
		||||
		self.on_checkbutton_toggled(widget, 'ignore_incoming_xhtml')
 | 
			
		||||
		helpers.update_optional_features()
 | 
			
		||||
 | 
			
		||||
	def apply_speller(self):
 | 
			
		||||
		for acct in gajim.connections:
 | 
			
		||||
| 
						 | 
				
			
			@ -719,12 +726,17 @@ class PreferencesWindow:
 | 
			
		|||
 | 
			
		||||
	def on_outgoing_chat_states_combobox_changed(self, widget):
 | 
			
		||||
		active = widget.get_active()
 | 
			
		||||
		old_value = gajim.config.get('outgoing_chat_state_notifications')
 | 
			
		||||
		if active == 0: # all
 | 
			
		||||
			gajim.config.set('outgoing_chat_state_notifications', 'all')
 | 
			
		||||
		elif active == 1: # only composing
 | 
			
		||||
			gajim.config.set('outgoing_chat_state_notifications', 'composing_only')
 | 
			
		||||
		else: # disabled
 | 
			
		||||
			gajim.config.set('outgoing_chat_state_notifications', 'disabled')
 | 
			
		||||
		new_value = gajim.config.get('outgoing_chat_state_notifications')
 | 
			
		||||
		if 'disabled' in (old_value, new_value):
 | 
			
		||||
			# we changed from disabled to sth else or vice versa
 | 
			
		||||
			helpers.update_optional_features()
 | 
			
		||||
 | 
			
		||||
	def on_displayed_chat_states_combobox_changed(self, widget):
 | 
			
		||||
		active = widget.get_active()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3106,6 +3106,8 @@ class Interface:
 | 
			
		|||
		if gajim.config.get('autodetect_browser_mailer') or not cfg_was_read:
 | 
			
		||||
			gtkgui_helpers.autodetect_browser_mailer()
 | 
			
		||||
 | 
			
		||||
		helpers.update_optional_features()
 | 
			
		||||
 | 
			
		||||
		if gajim.verbose:
 | 
			
		||||
			gajim.log.setLevel(gajim.logging.DEBUG)
 | 
			
		||||
		else:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue