2018-07-01 02:16:33 +02:00
|
|
|
# This file is part of Gajim.
|
|
|
|
#
|
|
|
|
# Gajim is free software; you can redistribute it and/or modify
|
|
|
|
# it under the terms of the GNU General Public License as published
|
|
|
|
# by the Free Software Foundation; version 3 only.
|
|
|
|
#
|
|
|
|
# Gajim is distributed in the hope that it will be useful,
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
# GNU General Public License for more details.
|
|
|
|
#
|
|
|
|
# You should have received a copy of the GNU General Public License
|
|
|
|
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
# XEP-0048: Bookmarks
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
from typing import Any
|
2018-12-17 00:41:46 +01:00
|
|
|
from typing import Dict
|
2018-12-12 23:28:30 +01:00
|
|
|
from typing import List
|
|
|
|
from typing import Optional
|
|
|
|
|
2018-07-01 02:16:33 +02:00
|
|
|
import logging
|
2018-09-04 23:00:29 +02:00
|
|
|
import copy
|
|
|
|
from collections import OrderedDict
|
2018-07-01 02:16:33 +02:00
|
|
|
|
|
|
|
import nbxmpp
|
2018-12-12 23:28:30 +01:00
|
|
|
from gi.repository import GLib
|
2018-07-01 02:16:33 +02:00
|
|
|
|
|
|
|
from gajim.common import app
|
|
|
|
from gajim.common import helpers
|
2018-12-12 23:28:30 +01:00
|
|
|
from gajim.common.const import PEPEventType
|
|
|
|
from gajim.common.nec import NetworkEvent
|
|
|
|
from gajim.common.exceptions import StanzaMalformed
|
|
|
|
from gajim.common.modules.pep import AbstractPEPModule
|
|
|
|
from gajim.common.modules.pep import AbstractPEPData
|
2018-09-05 00:06:59 +02:00
|
|
|
from gajim.common.modules.util import from_xs_boolean
|
|
|
|
from gajim.common.modules.util import to_xs_boolean
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
|
2018-07-01 02:16:33 +02:00
|
|
|
log = logging.getLogger('gajim.c.m.bookmarks')
|
|
|
|
|
2018-09-11 22:25:55 +02:00
|
|
|
NS_GAJIM_BM = 'xmpp:gajim.org/bookmarks'
|
|
|
|
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
class BookmarksData(AbstractPEPData):
|
|
|
|
|
|
|
|
type_ = PEPEventType.BOOKMARKS
|
|
|
|
|
|
|
|
|
|
|
|
class Bookmarks(AbstractPEPModule):
|
|
|
|
|
|
|
|
name = 'storage'
|
|
|
|
namespace = 'storage:bookmarks'
|
|
|
|
pep_class = BookmarksData
|
|
|
|
store_publish = False
|
|
|
|
_log = log
|
|
|
|
|
2018-07-01 02:16:33 +02:00
|
|
|
def __init__(self, con):
|
2018-12-12 23:28:30 +01:00
|
|
|
AbstractPEPModule.__init__(self, con)
|
|
|
|
|
2018-07-01 02:16:33 +02:00
|
|
|
self.bookmarks = {}
|
2018-12-04 20:41:30 +01:00
|
|
|
self.conversion = False
|
2018-12-12 23:28:30 +01:00
|
|
|
self._join_timeouts = []
|
|
|
|
self._request_in_progress = False
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-12-04 20:41:30 +01:00
|
|
|
def pass_disco(self, from_, _identities, features, _data, _node):
|
|
|
|
if nbxmpp.NS_BOOKMARK_CONVERSION not in features:
|
|
|
|
return
|
|
|
|
self.conversion = True
|
|
|
|
log.info('Discovered Bookmarks Conversion: %s', from_)
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
def _extract_info(self, item):
|
|
|
|
storage = item.getTag('storage', namespace=self.namespace)
|
|
|
|
if storage is None:
|
|
|
|
raise StanzaMalformed('No storage node')
|
|
|
|
return storage
|
|
|
|
|
|
|
|
def _notification_received(self, jid: nbxmpp.JID, user_pep: Any) -> None:
|
|
|
|
if self._request_in_progress:
|
|
|
|
log.info('Ignore update, pubsub request in progress')
|
|
|
|
return
|
|
|
|
|
2018-12-21 19:10:18 +01:00
|
|
|
if not self._con.get_own_jid().bareMatch(jid):
|
|
|
|
log.warning('%s has an open access bookmarks node', jid)
|
|
|
|
return
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
if not self._pubsub_support() or not self.conversion:
|
|
|
|
return
|
|
|
|
|
|
|
|
old_bookmarks = self._convert_to_set(self.bookmarks)
|
|
|
|
self.bookmarks = self._parse_bookmarks(user_pep.data)
|
|
|
|
self._act_on_changed_bookmarks(old_bookmarks)
|
|
|
|
app.nec.push_incoming_event(
|
|
|
|
NetworkEvent('bookmarks-received', account=self._account))
|
|
|
|
|
|
|
|
def _act_on_changed_bookmarks(self, old_bookmarks):
|
|
|
|
new_bookmarks = self._convert_to_set(self.bookmarks)
|
|
|
|
changed = new_bookmarks - old_bookmarks
|
|
|
|
if not changed:
|
|
|
|
return
|
|
|
|
|
|
|
|
join = [jid for jid, autojoin in changed if autojoin]
|
|
|
|
for jid in join:
|
|
|
|
log.info('Schedule autojoin in 10s for: %s', jid)
|
|
|
|
# If another client creates a MUC, the MUC is locked until the
|
|
|
|
# configuration is finished. Give the user some time to finish
|
|
|
|
# the configuration.
|
|
|
|
timeout_id = GLib.timeout_add_seconds(
|
|
|
|
10, self._join_with_timeout, join)
|
|
|
|
self._join_timeouts.append(timeout_id)
|
|
|
|
|
|
|
|
# TODO: leave mucs
|
|
|
|
# leave = [jid for jid, autojoin in changed if not autojoin]
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def _convert_to_set(bookmarks):
|
|
|
|
set_ = set()
|
|
|
|
for jid in bookmarks:
|
|
|
|
set_.add((jid, bookmarks[jid]['autojoin']))
|
|
|
|
return set_
|
|
|
|
|
2018-09-04 23:00:29 +02:00
|
|
|
def get_sorted_bookmarks(self, short_name=False):
|
|
|
|
# This returns a sorted by name copy of the bookmarks
|
|
|
|
sorted_bookmarks = {}
|
|
|
|
for jid, bookmarks in self.bookmarks.items():
|
|
|
|
bookmark_copy = copy.deepcopy(bookmarks)
|
|
|
|
if not bookmark_copy['name']:
|
|
|
|
# No name was given for this bookmark
|
|
|
|
# Use the first part of JID instead
|
|
|
|
name = jid.split("@")[0]
|
|
|
|
bookmark_copy['name'] = name
|
|
|
|
|
|
|
|
if short_name:
|
|
|
|
name = bookmark_copy['name']
|
|
|
|
name = (name[:42] + '..') if len(name) > 42 else name
|
|
|
|
bookmark_copy['name'] = name
|
|
|
|
|
|
|
|
sorted_bookmarks[jid] = bookmark_copy
|
|
|
|
return OrderedDict(
|
|
|
|
sorted(sorted_bookmarks.items(),
|
|
|
|
key=lambda bookmark: bookmark[1]['name'].lower()))
|
|
|
|
|
2018-09-12 21:07:59 +02:00
|
|
|
def _pubsub_support(self) -> bool:
|
2018-07-22 12:18:24 +02:00
|
|
|
return (self._con.get_module('PEP').supported and
|
|
|
|
self._con.get_module('PubSub').publish_options)
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
def get_bookmarks(self):
|
2018-07-01 02:16:33 +02:00
|
|
|
if not app.account_is_connected(self._account):
|
|
|
|
return
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
if self._pubsub_support() and self.conversion:
|
|
|
|
self._request_pubsub_bookmarks()
|
2018-07-01 02:16:33 +02:00
|
|
|
else:
|
|
|
|
self._request_private_bookmarks()
|
|
|
|
|
2018-09-12 21:07:59 +02:00
|
|
|
def _request_pubsub_bookmarks(self) -> None:
|
2018-07-01 02:16:33 +02:00
|
|
|
log.info('Request Bookmarks (PubSub)')
|
2018-12-12 23:28:30 +01:00
|
|
|
self._request_in_progress = True
|
2018-07-01 02:16:33 +02:00
|
|
|
self._con.get_module('PubSub').send_pb_retrieve(
|
2018-12-12 23:28:30 +01:00
|
|
|
'', 'storage:bookmarks', cb=self._bookmarks_received)
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-09-12 21:07:59 +02:00
|
|
|
def _request_private_bookmarks(self) -> None:
|
2018-12-12 23:28:30 +01:00
|
|
|
self._request_in_progress = True
|
2018-07-01 02:16:33 +02:00
|
|
|
iq = nbxmpp.Iq(typ='get')
|
|
|
|
query = iq.addChild(name='query', namespace=nbxmpp.NS_PRIVATE)
|
|
|
|
query.addChild(name='storage', namespace='storage:bookmarks')
|
|
|
|
log.info('Request Bookmarks (PrivateStorage)')
|
|
|
|
self._con.connection.SendAndCallForResponse(
|
2018-12-12 23:28:30 +01:00
|
|
|
iq, self._bookmarks_received, {})
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
def _bookmarks_received(self, _con, stanza):
|
|
|
|
self._request_in_progress = False
|
2018-07-01 02:16:33 +02:00
|
|
|
if not nbxmpp.isResultNode(stanza):
|
2018-12-12 23:28:30 +01:00
|
|
|
log.info('No bookmarks found: %s', stanza.getError())
|
2018-07-01 02:16:33 +02:00
|
|
|
else:
|
2018-12-12 23:28:30 +01:00
|
|
|
log.info('Received Bookmarks')
|
|
|
|
storage = self._get_storage_node(stanza)
|
|
|
|
if storage is not None:
|
|
|
|
self.bookmarks = self._parse_bookmarks(storage)
|
|
|
|
self.auto_join_bookmarks()
|
|
|
|
|
|
|
|
app.nec.push_incoming_event(
|
|
|
|
NetworkEvent('bookmarks-received', account=self._account))
|
2018-07-01 02:16:33 +02:00
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def _get_storage_node(stanza):
|
|
|
|
node = stanza.getTag('pubsub', namespace=nbxmpp.NS_PUBSUB)
|
|
|
|
if node is None:
|
|
|
|
node = stanza.getTag('event', namespace=nbxmpp.NS_PUBSUB_EVENT)
|
|
|
|
if node is None:
|
|
|
|
# Private Storage
|
|
|
|
query = stanza.getQuery()
|
|
|
|
if query is None:
|
|
|
|
return
|
|
|
|
storage = query.getTag('storage',
|
|
|
|
namespace=nbxmpp.NS_BOOKMARKS)
|
|
|
|
if storage is None:
|
|
|
|
return
|
|
|
|
return storage
|
|
|
|
|
|
|
|
items_node = node.getTag('items')
|
|
|
|
if items_node is None:
|
|
|
|
return
|
|
|
|
if items_node.getAttr('node') != nbxmpp.NS_BOOKMARKS:
|
|
|
|
return
|
|
|
|
|
|
|
|
item_node = items_node.getTag('item')
|
|
|
|
if item_node is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
storage = item_node.getTag('storage', namespace=nbxmpp.NS_BOOKMARKS)
|
|
|
|
if storage is None:
|
|
|
|
return
|
|
|
|
return storage
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
@staticmethod
|
2018-12-17 00:41:46 +01:00
|
|
|
def _parse_bookmarks(storage: nbxmpp.Node) -> Dict[str, Dict[str, Any]]:
|
2018-12-12 23:28:30 +01:00
|
|
|
bookmarks = {}
|
2018-07-01 02:16:33 +02:00
|
|
|
confs = storage.getTags('conference')
|
|
|
|
for conf in confs:
|
|
|
|
autojoin_val = conf.getAttr('autojoin')
|
|
|
|
if not autojoin_val: # not there (it's optional)
|
|
|
|
autojoin_val = False
|
2018-10-19 15:59:04 +02:00
|
|
|
|
2018-07-01 02:16:33 +02:00
|
|
|
minimize_val = conf.getTag('minimize', namespace=NS_GAJIM_BM)
|
2018-12-12 23:28:30 +01:00
|
|
|
if not minimize_val:
|
|
|
|
minimize_val = False
|
2018-07-01 02:16:33 +02:00
|
|
|
else:
|
|
|
|
minimize_val = minimize_val.getData()
|
|
|
|
|
|
|
|
print_status = conf.getTag('print_status', namespace=NS_GAJIM_BM)
|
|
|
|
if not print_status: # not there, try old Gajim behaviour
|
2018-12-12 23:28:30 +01:00
|
|
|
print_status = None
|
2018-07-01 02:16:33 +02:00
|
|
|
else:
|
|
|
|
print_status = print_status.getData()
|
|
|
|
|
|
|
|
try:
|
|
|
|
jid = helpers.parse_jid(conf.getAttr('jid'))
|
|
|
|
except helpers.InvalidFormat:
|
2018-09-11 22:25:55 +02:00
|
|
|
log.warning('Invalid JID: %s, ignoring it',
|
|
|
|
conf.getAttr('jid'))
|
2018-07-01 02:16:33 +02:00
|
|
|
continue
|
|
|
|
|
2018-10-19 15:59:04 +02:00
|
|
|
bookmark = {
|
|
|
|
'name': conf.getAttr('name'),
|
|
|
|
'password': conf.getTagData('password'),
|
|
|
|
'nick': conf.getTagData('nick'),
|
|
|
|
'print_status': print_status
|
|
|
|
}
|
|
|
|
|
|
|
|
try:
|
|
|
|
bookmark['autojoin'] = from_xs_boolean(autojoin_val)
|
|
|
|
bookmark['minimize'] = from_xs_boolean(minimize_val)
|
|
|
|
except ValueError as error:
|
|
|
|
log.warning(error)
|
|
|
|
continue
|
|
|
|
|
2018-07-01 02:16:33 +02:00
|
|
|
log.debug('Found Bookmark: %s', jid)
|
2018-12-12 23:28:30 +01:00
|
|
|
bookmarks[jid] = bookmark
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
return bookmarks
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
@staticmethod
|
|
|
|
def _build_storage_node(bookmarks):
|
2018-07-01 02:16:33 +02:00
|
|
|
storage_node = nbxmpp.Node(
|
|
|
|
tag='storage', attrs={'xmlns': 'storage:bookmarks'})
|
2018-12-12 23:28:30 +01:00
|
|
|
for jid, bm in bookmarks.items():
|
2018-07-01 02:16:33 +02:00
|
|
|
conf_node = storage_node.addChild(name="conference")
|
|
|
|
conf_node.setAttr('jid', jid)
|
2018-09-05 00:06:59 +02:00
|
|
|
conf_node.setAttr('autojoin', to_xs_boolean(bm['autojoin']))
|
2018-07-01 02:16:33 +02:00
|
|
|
conf_node.setAttr('name', bm['name'])
|
2018-09-05 00:06:59 +02:00
|
|
|
conf_node.setTag('minimize', namespace=NS_GAJIM_BM).setData(
|
|
|
|
to_xs_boolean(bm['minimize']))
|
2018-07-01 02:16:33 +02:00
|
|
|
# Only add optional elements if not empty
|
|
|
|
# Note: need to handle both None and '' as empty
|
|
|
|
# thus shouldn't use "is not None"
|
|
|
|
if bm.get('nick', None):
|
|
|
|
conf_node.setTagData('nick', bm['nick'])
|
|
|
|
if bm.get('password', None):
|
|
|
|
conf_node.setTagData('password', bm['password'])
|
|
|
|
if bm.get('print_status', None):
|
|
|
|
conf_node.setTag(
|
|
|
|
'print_status',
|
|
|
|
namespace=NS_GAJIM_BM).setData(bm['print_status'])
|
|
|
|
return storage_node
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
def _build_node(self, _data):
|
|
|
|
pass
|
|
|
|
|
2018-07-01 02:16:33 +02:00
|
|
|
@staticmethod
|
2018-09-12 21:07:59 +02:00
|
|
|
def get_bookmark_publish_options() -> nbxmpp.Node:
|
2018-07-01 02:16:33 +02:00
|
|
|
options = nbxmpp.Node(nbxmpp.NS_DATA + ' x',
|
|
|
|
attrs={'type': 'submit'})
|
2018-09-11 22:25:55 +02:00
|
|
|
field = options.addChild('field',
|
|
|
|
attrs={'var': 'FORM_TYPE', 'type': 'hidden'})
|
|
|
|
field.setTagData('value', nbxmpp.NS_PUBSUB_PUBLISH_OPTIONS)
|
|
|
|
field = options.addChild('field', attrs={'var': 'pubsub#access_model'})
|
|
|
|
field.setTagData('value', 'whitelist')
|
2018-07-01 02:16:33 +02:00
|
|
|
return options
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
def store_bookmarks(self):
|
2018-07-01 02:16:33 +02:00
|
|
|
if not app.account_is_connected(self._account):
|
|
|
|
return
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
storage_node = self._build_storage_node(self.bookmarks)
|
|
|
|
if self._pubsub_support() and self.conversion:
|
|
|
|
self._pubsub_store(storage_node)
|
|
|
|
else:
|
2018-07-01 02:16:33 +02:00
|
|
|
self._private_store(storage_node)
|
|
|
|
|
2018-09-12 21:07:59 +02:00
|
|
|
def _pubsub_store(self, storage_node: nbxmpp.Node) -> None:
|
2018-07-01 02:16:33 +02:00
|
|
|
self._con.get_module('PubSub').send_pb_publish(
|
|
|
|
'', 'storage:bookmarks', storage_node, 'current',
|
|
|
|
options=self.get_bookmark_publish_options(),
|
|
|
|
cb=self._pubsub_store_result)
|
|
|
|
log.info('Publish Bookmarks (PubSub)')
|
|
|
|
|
2018-09-12 21:07:59 +02:00
|
|
|
def _private_store(self, storage_node: nbxmpp.Node) -> None:
|
2018-07-01 02:16:33 +02:00
|
|
|
iq = nbxmpp.Iq('set', nbxmpp.NS_PRIVATE, payload=storage_node)
|
|
|
|
log.info('Publish Bookmarks (PrivateStorage)')
|
|
|
|
self._con.connection.SendAndCallForResponse(
|
|
|
|
iq, self._private_store_result)
|
|
|
|
|
2018-09-11 22:25:55 +02:00
|
|
|
@staticmethod
|
|
|
|
def _pubsub_store_result(_con, stanza):
|
2018-07-01 02:16:33 +02:00
|
|
|
if not nbxmpp.isResultNode(stanza):
|
|
|
|
log.error('Error: %s', stanza.getError())
|
|
|
|
return
|
|
|
|
|
2018-09-11 22:25:55 +02:00
|
|
|
@staticmethod
|
2018-09-12 21:07:59 +02:00
|
|
|
def _private_store_result(stanza: nbxmpp.Iq) -> None:
|
2018-07-01 02:16:33 +02:00
|
|
|
if not nbxmpp.isResultNode(stanza):
|
|
|
|
log.error('Error: %s', stanza.getError())
|
|
|
|
return
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
def _join_with_timeout(self, bookmarks: Optional[List[str]] = None) -> None:
|
|
|
|
self._join_timeouts.pop(0)
|
|
|
|
self.auto_join_bookmarks(bookmarks)
|
|
|
|
|
|
|
|
def auto_join_bookmarks(self, bookmarks: Optional[List[str]] = None) -> None:
|
2018-07-01 02:16:33 +02:00
|
|
|
if app.is_invisible(self._account):
|
|
|
|
return
|
2018-12-12 23:28:30 +01:00
|
|
|
if bookmarks is None:
|
|
|
|
bookmarks = self.bookmarks.keys()
|
|
|
|
|
|
|
|
for jid in bookmarks:
|
|
|
|
bookmark = self.bookmarks[jid]
|
|
|
|
if bookmark['autojoin']:
|
2018-07-01 02:16:33 +02:00
|
|
|
# Only join non-opened groupchats. Opened one are already
|
|
|
|
# auto-joined on re-connection
|
|
|
|
if jid not in app.gc_connected[self._account]:
|
|
|
|
# we are not already connected
|
2018-12-12 23:28:30 +01:00
|
|
|
log.info('Autojoin Bookmark: %s', jid)
|
2018-07-01 02:16:33 +02:00
|
|
|
app.interface.join_gc_room(
|
2018-12-12 23:28:30 +01:00
|
|
|
self._account, jid, bookmark['nick'],
|
|
|
|
bookmark['password'], minimize=bookmark['minimize'])
|
2018-07-01 02:16:33 +02:00
|
|
|
|
|
|
|
def add_bookmark(self, name, jid, autojoin,
|
|
|
|
minimize, password, nick):
|
|
|
|
self.bookmarks[jid] = {
|
|
|
|
'name': name,
|
|
|
|
'autojoin': autojoin,
|
|
|
|
'minimize': minimize,
|
|
|
|
'password': password,
|
2018-07-29 22:26:52 +02:00
|
|
|
'nick': nick,
|
|
|
|
'print_status': None}
|
2018-07-01 02:16:33 +02:00
|
|
|
|
|
|
|
self.store_bookmarks()
|
2018-12-12 23:28:30 +01:00
|
|
|
app.nec.push_incoming_event(
|
|
|
|
NetworkEvent('bookmarks-received', account=self._account))
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-09-12 21:07:59 +02:00
|
|
|
def get_name_from_bookmark(self, jid: str) -> str:
|
2018-09-08 23:53:12 +02:00
|
|
|
fallback = jid.split('@')[0]
|
2018-07-01 02:16:33 +02:00
|
|
|
try:
|
2018-09-08 23:53:12 +02:00
|
|
|
return self.bookmarks[jid]['name'] or fallback
|
2018-07-01 02:16:33 +02:00
|
|
|
except KeyError:
|
2018-09-08 23:53:12 +02:00
|
|
|
return fallback
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-09-12 21:07:59 +02:00
|
|
|
def purge_pubsub_bookmarks(self) -> None:
|
2018-07-01 02:16:33 +02:00
|
|
|
log.info('Purge/Delete Bookmarks on PubSub, '
|
|
|
|
'because publish options are not available')
|
|
|
|
self._con.get_module('PubSub').send_pb_purge('', 'storage:bookmarks')
|
|
|
|
self._con.get_module('PubSub').send_pb_delete('', 'storage:bookmarks')
|
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
def _remove_timeouts(self):
|
|
|
|
for _id in self._join_timeouts:
|
|
|
|
GLib.source_remove(_id)
|
2018-07-01 02:16:33 +02:00
|
|
|
|
2018-12-12 23:28:30 +01:00
|
|
|
def cleanup(self):
|
|
|
|
self._remove_timeouts()
|
2018-07-07 13:52:44 +02:00
|
|
|
|
|
|
|
|
|
|
|
def get_instance(*args, **kwargs):
|
|
|
|
return Bookmarks(*args, **kwargs), 'Bookmarks'
|