common/uilib.py
author Sandrine Ribeau <sandrine.ribeau@logilab.fr>
Tue, 23 Dec 2008 13:38:02 -0800
changeset 291 87c8d96f6173
parent 277 a11a3c231050
child 350 f34ef2c64605
permissions -rw-r--r--
[doc] Renaming for advanced thematic.

# -*- coding: utf-8 -*-
"""user interface libraries

contains some functions designed to help implementation of cubicweb user interface

:organization: Logilab
:copyright: 2001-2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
"""
__docformat__ = "restructuredtext en"

import csv
import decimal
import locale
import re
from urllib import quote as urlquote
from cStringIO import StringIO
from xml.parsers.expat import ExpatError
from copy import deepcopy

import simplejson

from mx.DateTime import DateTimeType, DateTimeDeltaType

from logilab.common.textutils import unormalize

def ustrftime(date, fmt='%Y-%m-%d'):
    """like strftime, but returns a unicode string instead of an encoded
    string which may be problematic with localized date.
    
    encoding is guessed by locale.getpreferredencoding()
    """
    # date format may depend on the locale
    encoding = locale.getpreferredencoding(do_setlocale=False) or 'UTF-8'
    return unicode(date.strftime(fmt), encoding)


def rql_for_eid(eid):
    """return the rql query necessary to fetch entity with the given eid.  This
    function should only be used to generate link with rql inside, not to give
    to cursor.execute (in which case you won't benefit from rql cache).

    :Parameters:
      - `eid`: the eid of the entity we should search
    :rtype: str
    :return: the rql query
    """
    return 'Any X WHERE X eid %s' % eid


def printable_value(req, attrtype, value, props=None, displaytime=True):
    """return a displayable value (i.e. unicode string)"""
    if value is None or attrtype == 'Bytes':
        return u''
    if attrtype == 'String':
        # don't translate empty value if you don't want strange results
        if props is not None and value and props.get('internationalizable'):
            return req._(value)
        
        return value
    if attrtype == 'Date':
        return ustrftime(value, req.property_value('ui.date-format'))
    if attrtype == 'Time':
        return ustrftime(value, req.property_value('ui.time-format'))
    if attrtype == 'Datetime':
        if not displaytime:
            return ustrftime(value, req.property_value('ui.date-format'))
        return ustrftime(value, req.property_value('ui.datetime-format'))
    if attrtype == 'Boolean':
        if value:
            return req._('yes')
        return req._('no')
    if attrtype == 'Float':
        value = req.property_value('ui.float-format') % value
    return unicode(value)


# text publishing #############################################################

try:
    from cubicweb.common.rest import rest_publish # pylint: disable-msg=W0611
except ImportError:
    def rest_publish(entity, data):
        """default behaviour if docutils was not found"""
        return data
    
TAG_PROG = re.compile(r'</?.*?>', re.U)
def remove_html_tags(text):
    """Removes HTML tags from text

    >>> remove_html_tags('<td>hi <a href="http://www.google.fr">world</a></td>')
    'hi world'
    >>>
    """
    return TAG_PROG.sub('', text)


REF_PROG = re.compile(r"<ref\s+rql=([\'\"])([^\1]*?)\1\s*>([^<]*)</ref>", re.U)
def _subst_rql(view, obj):
    delim, rql, descr = obj.groups()
    return u'<a href="%s">%s</a>' % (view.build_url(rql=rql), descr)

def html_publish(view, text):
    """replace <ref rql=''> links by <a href="...">"""
    if not text:
        return u''
    return REF_PROG.sub(lambda obj, view=view:_subst_rql(view, obj), text)

# fallback implementation, nicer one defined below if lxml is available
def soup2xhtml(data, encoding):
    return data

# fallback implementation, nicer one defined below if lxml> 2.0 is available
def safe_cut(text, length):
    """returns a string of length <length> based on <text>, removing any html
    tags from given text if cut is necessary."""
    if text is None:
        return u''
    text_nohtml = remove_html_tags(text)
    # try to keep html tags if text is short enough
    if len(text_nohtml) <= length:
        return text
    # else if un-tagged text is too long, cut it
    return text_nohtml[:length-3] + u'...'


try:
    from lxml import etree
except (ImportError, AttributeError):
    # gae environment: lxml not availabel
    pass
else:

    def soup2xhtml(data, encoding):
        """tidy (at least try) html soup and return the result
        Note: the function considers a string with no surrounding tag as valid
              if <div>`data`</div> can be parsed by an XML parser
        """
        xmltree = etree.HTML('<div>%s</div>' % data)
        # NOTE: lxml 1.1 (etch platforms) doesn't recognize
        #       the encoding=unicode parameter (lxml 2.0 does), this is
        #       why we specify an encoding and re-decode to unicode later
        body = etree.tostring(xmltree[0], encoding=encoding)
        # remove <body> and </body> and decode to unicode
        return body[11:-13].decode(encoding)

    if hasattr(etree.HTML('<div>test</div>'), 'iter'):

        def safe_cut(text, length):
            """returns an html document of length <length> based on <text>,
            and cut is necessary.
            """
            if text is None:
                return u''
            textParse = etree.HTML(text)
            compteur = 0

            for element in textParse.iter():
                if compteur > length:
                    parent = element.getparent()
                    parent.remove(element)
                else:
                    if element.text is not None:
                        text_resum = text_cut_letters(element.text,length)
                        len_text_resum = len(''.join(text_resum.split()))
                        compteur = compteur + len_text_resum
                        element.text = text_resum

                    if element.tail is not None:
                        if compteur < length:
                            text_resum = text_cut_letters(element.tail,length)
                            len_text_resum = len(''.join(text_resum.split()))
                            compteur = compteur + len_text_resum
                            element.tail = text_resum
                        else:
                            element.tail = ''

            div = etree.HTML('<div></div>')[0][0]
            listNode = textParse[0].getchildren()
            for node in listNode:
                div.append(deepcopy(node))
            return etree.tounicode(div)

    
# HTML generation helper functions ############################################

from logilab.mtconverter import html_escape

def tooltipize(text, tooltip, url=None):
    """make an HTML tooltip"""
    url = url or '#'
    return u'<a href="%s" title="%s">%s</a>' % (url, tooltip, text)

def toggle_action(nodeid):
    """builds a HTML link that uses the js toggleVisibility function"""
    return u"javascript: toggleVisibility('%s')" % nodeid

def toggle_link(nodeid, label):
    """builds a HTML link that uses the js toggleVisibility function"""
    return u'<a href="%s">%s</a>' % (toggle_action(nodeid), label)

def ajax_replace_url(nodeid, rql, vid=None, swap=False, **extraparams):
    """builds a replacePageChunk-like url
    >>> ajax_replace_url('foo', 'Person P')
    "javascript: replacePageChunk('foo', 'Person%20P');"
    >>> ajax_replace_url('foo', 'Person P', 'oneline')
    "javascript: replacePageChunk('foo', 'Person%20P', 'oneline');"
    >>> ajax_replace_url('foo', 'Person P', 'oneline', name='bar', age=12)
    "javascript: replacePageChunk('foo', 'Person%20P', 'oneline', {'age':12, 'name':'bar'});"
    >>> ajax_replace_url('foo', 'Person P', name='bar', age=12)
    "javascript: replacePageChunk('foo', 'Person%20P', 'null', {'age':12, 'name':'bar'});"    
    """
    params = [repr(nodeid), repr(urlquote(rql))]
    if extraparams and not vid:
        params.append("'null'")
    elif vid:
        params.append(repr(vid))
    if extraparams:
        params.append(simplejson.dumps(extraparams))
    if swap:
        params.append('true')
    return "javascript: replacePageChunk(%s);" % ', '.join(params)

def text_cut(text, nbwords=30):
    if text is None:
        return u''
    minlength = len(' '.join(text.split()[:nbwords]))
    textlength = text.find('.', minlength) + 1
    if textlength == 0: # no point found
        textlength = minlength 
    return text[:textlength]

def text_cut_letters(text, nbletters):
    if text is None:
        return u''
    if len(''.join(text.split())) <= nbletters:
           return text
    else:
        text_nospace = ''.join(text.split())
        textlength=text.find('.') + 1

        if textlength==0:
           textlength=text.find(' ', nbletters+5)
           
        return text[:textlength] 

def cut(text, length):
    """returns a string of length <length> based on <text>
    post:
      len(__return__) <= length
    """
    if text is None:
        return u''
    if len(text) <= length:
        return text
    # else if un-tagged text is too long, cut it
    return text[:length-3] + u'...'


from StringIO import StringIO

def ureport_as_html(layout):
    from logilab.common.ureports import HTMLWriter
    formater = HTMLWriter(True)
    stream = StringIO() #UStringIO() don't want unicode assertion
    formater.format(layout, stream)
    res = stream.getvalue()
    if isinstance(res, str):
        res = unicode(res, 'UTF8')
    return res

def render_HTML_tree(tree, selected_node=None, render_node=None, caption=None):
    """
    Generate a pure HTML representation of a tree given as an instance
    of a logilab.common.tree.Node

    selected_node is the currently selected node (if any) which will
    have its surrounding <div> have id="selected" (which default
    to a bold border libe with the default CSS).

    render_node is a function that should take a Node content (Node.id)
    as parameter and should return a string (what will be displayed
    in the cell).

    Warning: proper rendering of the generated html code depends on html_tree.css
    """
    tree_depth = tree.depth_down()
    if render_node is None:
        render_node = str

    # helper function that build a matrix from the tree, like:
    # +------+-----------+-----------+
    # | root | child_1_1 | child_2_1 |
    # | root | child_1_1 | child_2_2 |
    # | root | child_1_2 |           |
    # | root | child_1_3 | child_2_3 |
    # | root | child_1_3 | child_2_4 |
    # +------+-----------+-----------+
    # from:
    # root -+- child_1_1 -+- child_2_1
    #       |             |
    #       |             +- child_2_2
    #       +- child_1_2
    #       |
    #       +- child1_3 -+- child_2_3
    #                    |
    #                    +- child_2_2
    def build_matrix(path, matrix):
        if path[-1].is_leaf():
            matrix.append(path[:])
        else:
            for child in path[-1].children:
                build_matrix(path[:] + [child], matrix)
        
    matrix = []
    build_matrix([tree], matrix)

    # make all lines in the matrix have the same number of columns
    for line in matrix:
        line.extend([None]*(tree_depth-len(line)))
    for i in range(len(matrix)-1, 0, -1):
        prev_line, line = matrix[i-1:i+1]
        for j in range(len(line)):
            if line[j] == prev_line[j]:
                line[j] = None

    # We build the matrix of link types (between 2 cells on a line of the matrix)
    # link types are :
    link_types = {(True,  True,  True ): 1, # T
                  (False, False, True ): 2, # |
                  (False, True,  True ): 3, # + (actually, vert. bar with horiz. bar on the right)
                  (False, True,  False): 4, # L
                  (True,  True,  False): 5, # -
                  }
    links = []
    for i, line in enumerate(matrix):
        links.append([])
        for j in range(tree_depth-1):
            cell_11 = line[j] is not None
            cell_12 = line[j+1] is not None
            cell_21 = line[j+1] is not None and line[j+1].next_sibling() is not None
            link_type = link_types.get((cell_11, cell_12, cell_21), 0)
            if link_type == 0 and i > 0 and links[i-1][j] in (1,2,3):
                link_type = 2
            links[-1].append(link_type)
    

    # We can now generate the HTML code for the <table> 
    s = u'<table class="tree">\n'
    if caption:
        s += '<caption>%s</caption>\n' % caption

    for i, link_line in enumerate(links):
        line = matrix[i]

        s += '<tr>'
        for j, link_cell in enumerate(link_line):
            cell = line[j]
            if cell:
                if cell.id == selected_node:
                    s += '<td class="tree_cell" rowspan="2"><div id="selected" class="tree_cell">%s</div></td>' % (render_node(cell.id))
                else:
                    s += '<td class="tree_cell" rowspan="2"><div class="tree_cell">%s</div></td>' % (render_node(cell.id))
            else:
                s += '<td rowspan="2">&nbsp;</td>'
            s += '<td class="tree_cell_%d_1">&nbsp;</td>' % link_cell
            s += '<td class="tree_cell_%d_2">&nbsp;</td>' % link_cell
                
        cell = line[-1]
        if cell:
            if cell.id == selected_node:
                s += '<td class="tree_cell" rowspan="2"><div id="selected" class="tree_cell">%s</div></td>' % (render_node(cell.id))
            else:
                s += '<td class="tree_cell" rowspan="2"><div class="tree_cell">%s</div></td>' % (render_node(cell.id))
        else:
            s += '<td rowspan="2">&nbsp;</td>'

        s += '</tr>\n'
        if link_line:
            s += '<tr>'
            for j, link_cell in enumerate(link_line):
                s += '<td class="tree_cell_%d_3">&nbsp;</td>' % link_cell
                s += '<td class="tree_cell_%d_4">&nbsp;</td>' % link_cell
            s += '</tr>\n'

    s += '</table>'
    return s



# traceback formatting ########################################################

import traceback

def rest_traceback(info, exception):
    """return a ReST formated traceback"""
    res = [u'Traceback\n---------\n::\n']
    for stackentry in traceback.extract_tb(info[2]):
        res.append(u'\tFile %s, line %s, function %s' % tuple(stackentry[:3]))
        if stackentry[3]:
            res.append(u'\t  %s' % stackentry[3].decode('utf-8', 'replace'))
    res.append(u'\n')
    try:
        res.append(u'\t Error: %s\n' % exception)
    except:
        pass
    return u'\n'.join(res)


def html_traceback(info, exception, title='',
                   encoding='ISO-8859-1', body=''):
    """ return an html formatted traceback from python exception infos.
    """
    tcbk = info[2]
    stacktb = traceback.extract_tb(tcbk)
    strings = []
    if body:
        strings.append(u'<div class="error_body">')
        # FIXME
        strings.append(body)
        strings.append(u'</div>')
    if title:
        strings.append(u'<h1 class="error">%s</h1>'% html_escape(title))
    try:
        strings.append(u'<p class="error">%s</p>' % html_escape(str(exception)).replace("\n","<br />"))
    except UnicodeError:
        pass
    strings.append(u'<div class="error_traceback">')
    for index, stackentry in enumerate(stacktb):
        strings.append(u'<b>File</b> <b class="file">%s</b>, <b>line</b> '
                       u'<b class="line">%s</b>, <b>function</b> '
                       u'<b class="function">%s</b>:<br/>'%(
            html_escape(stackentry[0]), stackentry[1], html_escape(stackentry[2])))
        if stackentry[3]:
            string = html_escape(stackentry[3]).decode('utf-8', 'replace')
            strings.append(u'&nbsp;&nbsp;%s<br/>\n' % (string))
        # add locals info for each entry
        try:
            local_context = tcbk.tb_frame.f_locals
            html_info = []
            chars = 0
            for name, value in local_context.iteritems():
                value = html_escape(repr(value))
                info = u'<span class="name">%s</span>=%s, ' % (name, value)
                line_length = len(name) + len(value)
                chars += line_length
                # 150 is the result of *years* of research ;-) (CSS might be helpful here)
                if chars > 150:
                    info = u'<br/>' + info
                    chars = line_length
                html_info.append(info)
            boxid = 'ctxlevel%d' % index
            strings.append(u'[%s]' % toggle_link(boxid, '+'))
            strings.append(u'<div id="%s" class="pycontext hidden">%s</div>' %
                           (boxid, ''.join(html_info)))
            tcbk = tcbk.tb_next
        except Exception:
            pass # doesn't really matter if we have no context info    
    strings.append(u'</div>')
    return '\n'.join(strings)

# csv files / unicode support #################################################

class UnicodeCSVWriter:
    """proxies calls to csv.writer.writerow to be able to deal with unicode"""
    
    def __init__(self, wfunc, encoding, **kwargs):
        self.writer = csv.writer(self, **kwargs)
        self.wfunc = wfunc
        self.encoding = encoding

    def write(self, data):
        self.wfunc(data)

    def writerow(self, row):
        csvrow = []
        for elt in row:
            if isinstance(elt, unicode):
                csvrow.append(elt.encode(self.encoding))
            else:
                csvrow.append(str(elt))
        self.writer.writerow(csvrow)

    def writerows(self, rows):
        for row in rows:
            self.writerow(row)


# some decorators #############################################################

class limitsize(object):
    def __init__(self, maxsize):
        self.maxsize = maxsize

    def __call__(self, function):
        def newfunc(*args, **kwargs):
            ret = function(*args, **kwargs)
            if isinstance(ret, basestring):
                return ret[:self.maxsize]
            return ret
        return newfunc


def jsonize(function):
    import simplejson
    def newfunc(*args, **kwargs):
        ret = function(*args, **kwargs)
        if isinstance(ret, decimal.Decimal):
            ret = float(ret)
        elif isinstance(ret, DateTimeType):
            ret = ret.strftime('%Y-%m-%d %H:%M')
        elif isinstance(ret, DateTimeDeltaType):
            ret = ret.seconds
        try:
            return simplejson.dumps(ret)
        except TypeError:
            return simplejson.dumps(repr(ret))
    return newfunc


def htmlescape(function):
    def newfunc(*args, **kwargs):
        ret = function(*args, **kwargs)
        assert isinstance(ret, basestring)
        return html_escape(ret)
    return newfunc