view frontends/src/quick_frontend/quick_blog.py @ 2055:ed33cd382bf9

plugin XEP-0245: removed plugin XEP-0245: at the end it adds complications to handle XEP-0245 in backend (because edge cases must be handled: info type with group chat, history when message is info but for a group chat, etc) while it's trivial to handle it in frontend. So /me detection is handled in QuickFrontend and cmd_me is back into plugin_misc_text_commands, it is the choice of the frontend to display or not the "/me" (for frontends based on QuickFrontend it remains automatic).
author Goffi <goffi@goffi.org>
date Sun, 28 Aug 2016 21:00:53 +0200
parents 2daf7b4c6756
children 6a2fa651d7fa
line wrap: on
line source

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# helper class for making a SAT frontend
# Copyright (C) 2011-2016 Jérôme Poisson <goffi@goffi.org>

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program 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 Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

# from sat.core.i18n import _, D_
from sat.core.log import getLogger
log = getLogger(__name__)


from sat_frontends.quick_frontend.constants import Const as C
from sat_frontends.quick_frontend import quick_widgets
from sat_frontends.tools import jid
from sat.tools.common import data_format

try:
    # FIXME: to be removed when an acceptable solution is here
    unicode('') # XXX: unicode doesn't exist in pyjamas
except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options
    unicode = str

ENTRY_CLS = None
COMMENTS_CLS = None


class Item(object):
    """Manage all (meta)data of an item"""

    def __init__(self, data):
        """
        @param data(dict): microblog data as return by bridge methods
            if data values are not defined, set default values
        """
        self.id = data['id']
        self.title = data.get('title')
        self.title_rich = None
        self.title_xhtml = data.get('title_xhtml')
        self.tags = list(data_format.dict2iter('tag', data))
        self.content = data.get('content')
        self.content_rich = None
        self.content_xhtml = data.get('content_xhtml')
        self.author = data['author']
        try:
            author_jid = data['author_jid']
            self.author_jid = jid.JID(author_jid) if author_jid else None
        except KeyError:
            self.author_jid = None

        try:
            self.author_verified = C.bool(data['author_jid_verified'])
        except KeyError:
            self.author_verified = False

        try:
            self.updated = float(data['updated'])  # XXX: int doesn't work here (pyjamas bug)
        except KeyError:
            self.updated = None

        try:
            self.published = float(data['published'])  # XXX: int doesn't work here (pyjamas bug)
        except KeyError:
            self.published = None

        self.comments = data.get('comments')
        try:
            self.comments_service = jid.JID(data['comments_service'])
        except KeyError:
            self.comments_service = None
        self.comments_node = data.get('comments_node')

    # def loadComments(self):
    #     """Load all the comments"""
    #     index = str(main_entry.comments_count - main_entry.hidden_count)
    #     rsm = {'max': str(main_entry.hidden_count), 'index': index}
    #     self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm)


class EntriesManager(object):
    """Class which manages list of (micro)blog entries"""

    def __init__(self, manager):
        """
        @param manager (EntriesManager, None): parent EntriesManager
            must be None for QuickBlog (and only for QuickBlog)
        """
        self.manager = manager
        if manager is None:
            self.blog = self
        else:
            self.blog = manager.blog
        self.entries = []
        self.edit_entry = None

    @property
    def level(self):
        """indicate how deep is this entry in the tree

        if level == -1, we have a QuickBlog
        if level == 0, we have a main item
        else we have a comment
        """
        level = -1
        manager = self.manager
        while manager is not None:
            level += 1
            manager = manager.manager
        return level

    def _addMBItems(self, items_tuple, service=None, node=None):
        """Add Microblog items to this panel
        update is NOT called after addition

        @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGet
        """
        items, metadata = items_tuple
        for item in items:
            self.addEntry(item, service=service, node=node, with_update=False)

    def _addMBItemsWithComments(self, items_tuple, service=None, node=None):
        """Add Microblog items to this panel
        update is NOT called after addition

        @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGet
        """
        items, metadata = items_tuple
        for item, comments in items:
            self.addEntry(item, comments, service=service, node=node, with_update=False)

    def addEntry(self, item=None, comments=None, service=None, node=None, with_update=True, editable=False, edit_entry=False):
        """Add a microblog entry

        @param editable (bool): True if the entry can be modified
        @param item (dict, None): blog item data, or None for an empty entry
        @param comments (list, None): list of comments data if available
        @param service (jid.JID, None): service where the entry is coming from
        @param service (unicode, None): node hosting the entry
        @param with_update (bool): if True, udpate is called with the new entry
        @param edit_entry(bool): if True, will be in self.edit_entry instead of
            self.entries, so it can be managed separately (e.g. first or last
            entry regardless of sorting)
        """
        new_entry = ENTRY_CLS(self, item, comments, service=service, node=node)
        new_entry.setEditable(editable)
        if edit_entry:
            self.edit_entry = new_entry
        else:
            self.entries.append(new_entry)
        if with_update:
            self.update()
        return new_entry

    def update(self, entry=None):
        """Update the display with entries

        @param entry (Entry, None): if not None, must be the new entry.
            If None, all the items will be checked to update the display
        """
        # update is separated from addEntry to allow adding
        # several entries at once, and updating at the end
        raise NotImplementedError


class Entry(EntriesManager):
    """Graphical representation of an Item
    This class must be overriden by frontends"""

    def __init__(self, manager, item_data=None, comments_data=None, service=None, node=None):
        """
        @param blog(QuickBlog): the parent QuickBlog
        @param manager(EntriesManager): the parent EntriesManager
        @param item_data(dict, None): dict containing the blog item data, or None for an empty entry
        @param comments_data(list, None): list of comments data
        """
        assert manager is not None
        EntriesManager.__init__(self, manager)
        self.service = service
        self.node = node
        self.editable = False
        self.reset(item_data)
        self.blog.id2entries[self.item.id] = self
        if self.item.comments:
            node_tuple = (self.item.comments_service, self.item.comments_node)
            self.blog.node2entries.setdefault(node_tuple,[]).append(self)

    def reset(self, item_data):
        """Reset the entry with given data

        used during init (it's a set and not a reset then)
        or later (e.g. message sent, or cancellation of an edition
        @param idem_data(dict, None): data as in __init__
        """
        if item_data is None:
            self.new = True
            item_data = {'id': None,
                         # TODO: find a best author value
                         'author': self.blog.host.whoami.node
                        }
        else:
            self.new = False
        self.item = Item(item_data)
        self.author_jid =  self.blog.host.whoami.bare if self.new else self.item.author_jid
        if self.author_jid is None and self.service and self.service.node:
            self.author_jid = self.service
        self.mode = C.ENTRY_MODE_TEXT if self.item.content_xhtml is None else C.ENTRY_MODE_XHTML

    def refresh(self):
        """Refresh the display when data have been modified"""
        pass

    def setEditable(self, editable=True):
        """tell if the entry can be edited or not

        @param editable(bool): True if the entry can be edited
        """
        #XXX: we don't use @property as property setter doesn't play well with pyjamas
        raise NotImplementedError

    def addComments(self, comments_data):
        """Add comments to this entry by calling addEntry repeatidly

        @param comments_data(tuple): data as returned by mbGetFromMany*RTResults
        """
        # TODO: manage seperator between comments of coming from different services/nodes
        for data in comments_data:
            service, node, failure, comments, metadata = data
            for comment in comments:
                if not failure:
                    self.addEntry(comment, service=jid.JID(service), node=node)
                else:
                    log.warning("getting comment failed: {}".format(failure))
        self.update()

    def send(self):
        """Send entry according to parent QuickBlog configuration and current level"""

        # keys to keep other than content*, title* and tag*
        # FIXME: see how to avoid comments node hijacking (someone could bind his post to another post's comments node)
        keys_to_keep = ('id', 'comments', 'author', 'author_jid', 'published')

        mb_data = {}
        for key in keys_to_keep:
            value = getattr(self.item, key)
            if value is not None:
                mb_data[key] = unicode(value)

        for prefix in ('content', 'title'):
            for suffix in ('', '_rich', '_xhtml'):
                name = '{}{}'.format(prefix, suffix)
                value = getattr(self.item, name)
                if value is not None:
                    mb_data[name] = value

        data_format.iter2dict('tag', self.item.tags, mb_data)

        if self.blog.new_message_target not in (C.PUBLIC, C.GROUP):
            raise NotImplementedError

        if self.level == 0:
            mb_data["allow_comments"] = C.BOOL_TRUE

        if self.blog.new_message_target == C.GROUP:
            data_format.iter2dict('group', self.blog.targets, mb_data)

        self.blog.host.bridge.mbSend(
            unicode(self.service or ''),
            self.node or '',
            mb_data,
            profile=self.blog.profile)

    def delete(self):
        """Remove this Entry from parent manager

        This doesn't delete any entry in PubSub, just locally
        all children entries will be recursively removed too
        """
        # XXX: named delete and not remove to avoid conflict with pyjamas
        log.debug(u"deleting entry {}".format('EDIT ENTRY' if self.new else self.item.id))
        for child in self.entries:
            child.delete()
        try:
            self.manager.entries.remove(self)
        except ValueError:
            if self != self.manager.edit_entry:
                log.error(u"Internal Error: entry not found in manager")
            else:
                self.manager.edit_entry = None
        if not self.new:
            # we must remove references to self
            # in QuickBlog's dictionary
            del self.blog.id2entries[self.item.id]
            if self.item.comments:
                comments_tuple = (self.item.comments_service,
                                  self.item.comments_node)
                other_entries = self.blog.node2entries[comments_tuple].remove(self)
                if not other_entries:
                    del self.blog.node2entries[comments_tuple]

    def retract(self):
        """Retract this item from microblog node

        if there is a comments node, it will be purged too
        """
        # TODO: manage several comments nodes case.
        if self.item.comments:
            self.blog.host.bridge.psDeleteNode(unicode(self.item.comments_service) or "", self.item.comments_node, profile=self.blog.profile)
        self.blog.host.bridge.mbRetract(unicode(self.service or ""), self.node or "", self.item.id, profile=self.blog.profile)


class QuickBlog(EntriesManager, quick_widgets.QuickWidget):

    def __init__(self, host, targets, profiles=None):
        """Panel used to show microblog

        @param targets (tuple(unicode)): contact groups displayed in this panel.
            If empty, show all microblogs from all contacts. targets is also used
            to know where to send new messages.
        """
        EntriesManager.__init__(self, None)
        self.id2entries = {} # used to find an entry with it's item id
                             # must be kept up-to-date by Entry
        self.node2entries = {} # same as above, values are lists in case of
                               # two entries link to the same comments node
        if not targets:
            targets = () # XXX: we use empty tuple instead of None to workaround a pyjamas bug
            quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE)
            self._targets_type = C.ALL
        else:
            assert isinstance(targets[0], basestring)
            quick_widgets.QuickWidget.__init__(self, host, targets[0], C.PROF_KEY_NONE)
            for target in targets[1:]:
                assert isinstance(target, basestring)
                self.addTarget(target)
            self._targets_type = C.GROUP

    @property
    def new_message_target(self):
        if self._targets_type == C.ALL:
            return C.PUBLIC
        elif self._targets_type == C.GROUP:
            return C.GROUP
        else:
            raise ValueError("Unkown targets type")

    def __str__(self):
        return u"Blog Widget [target: {}, profile: {}]".format(', '.join(self.targets), self.profile)

    def _getResultsCb(self, data, rt_session):
        remaining, results = data
        log.debug("Got {got_len} results, {rem_len} remaining".format(got_len=len(results), rem_len=remaining))
        for result in results:
            service, node, failure, items, metadata = result
            if not failure:
                self._addMBItemsWithComments((items, metadata), service=jid.JID(service))

        self.update()
        if remaining:
            self._getResults(rt_session)

    def _getResultsEb(self, failure):
        log.warning("microblog getFromMany error: {}".format(failure))

    def _getResults(self, rt_session):
        """Manage results from mbGetFromMany RT Session

        @param rt_session(str): session id as returned by mbGetFromMany
        """
        self.host.bridge.mbGetFromManyWithCommentsRTResult(rt_session, profile=self.profile,
                                               callback=lambda data:self._getResultsCb(data, rt_session),
                                               errback=self._getResultsEb)

    def getAll(self):
        """Get all (micro)blogs from self.targets"""
        def gotSession(rt_session):
            self._getResults(rt_session)

        if self._targets_type in (C.ALL, C.GROUP):
            targets = tuple(self.targets) if self._targets_type is C.GROUP else ()
            self.host.bridge.mbGetFromManyWithComments(self._targets_type, targets, 10, 10, {}, {"subscribe":C.BOOL_TRUE}, profile=self.profile, callback=gotSession)
            own_pep = self.host.whoami.bare
            self.host.bridge.mbGetFromManyWithComments(C.JID, (unicode(own_pep),), 10, 10, {}, {}, profile=self.profile, callback=gotSession)
        else:
            raise NotImplementedError(u'{} target type is not managed'.format(self._targets_type))

    def isJidAccepted(self, jid_):
        """Tell if a jid is actepted and must be shown in this panel

        @param jid_(jid.JID): jid to check
        @return: True if the jid is accepted
        """
        if self._targets_type == C.ALL:
            return True
        assert self._targets_type is C.GROUP # we don't manage other types for now
        for group in self.targets:
            if self.host.contact_lists[self.profile].isEntityInGroup(jid_, group):
                return True
        return False

    def addEntryIfAccepted(self, service, node, mb_data, groups, profile):
        """add entry to this panel if it's acceptable

        This method check if the entry is new or an update,
        if it below to a know node, or if it acceptable anyway
        @param service(jid.JID): jid of the emitting pubsub service
        @param node(unicode): node identifier
        @param mb_data: microblog data
        @param groups(list[unicode], None): groups which can receive this entry
            None to accept everything
        @param profile: %(doc_profile)s
        """
        try:
            entry = self.id2entries[mb_data['id']]
        except KeyError:
            # The entry is new
            try:
                parent_entries = self.node2entries[(service, node)]
            except:
                # The node is unknown,
                # we need to check that we can accept the entry
                if (self.isJidAccepted(service)
                    or (groups is None and service == self.host.profiles[self.profile].whoami.bare)
                    or (groups and groups.intersection(self.targets))):
                    self.addEntry(mb_data, service=service, node=node)
            else:
                # the entry is a comment in a known node
                for parent_entry in parent_entries:
                    parent_entry.addEntry(mb_data, service=service, node=node)
        else:
            # The entry exist, it's an update
            entry.reset(mb_data)
            entry.refresh()

    def deleteEntryIfPresent(self, service, node, item_id, profile):
        """Delete and entry if present in this QuickBlog

        @param sender(jid.JID): jid of the entry sender
        @param mb_data: microblog data
        @param service(jid.JID): sending service
        @param node(unicode): hosting node
        """
        try:
            entry = self.id2entries[item_id]
        except KeyError:
            pass
        else:
            entry.delete()


def registerClass(type_, cls):
    global ENTRY_CLS, COMMENTS_CLS
    if type_ == "ENTRY":
        ENTRY_CLS = cls
    elif type == "COMMENT":
        COMMENTS_CLS = cls
    else:
        raise ValueError("type_ should be ENTRY or COMMENT")
    if COMMENTS_CLS is None:
        COMMENTS_CLS = ENTRY_CLS