source: communesplone/PloneMeeting/trunk/utils.py @ 9924

Revision 9924, 51.4 KB checked in by gbastien, 2 months ago (diff)

Backported last changes from branch 2.1 about sending an email when an item is cloned to another meetingConfig and about avoiding errors if no meetingConfig exists

Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (c) 2012 by PloneGov
4#
5# GNU General Public License (GPL)
6#
7# This program is free software; you can redistribute it and/or
8# modify it under the terms of the GNU General Public License
9# as published by the Free Software Foundation; either version 2
10# of the License, or (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program; if not, write to the Free Software
19# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
20# 02110-1301, USA.
21#
22
23import os, re, urlparse, os.path, socket, email, cgi
24from appy.shared.diff import HtmlDiff
25from email.MIMEMultipart import MIMEMultipart
26from email.MIMEBase import MIMEBase
27from email.MIMEText import MIMEText
28from email import Encoders
29from appy.shared.xml_parser import XmlMarshaller
30from DateTime import DateTime
31from AccessControl import getSecurityManager, Unauthorized
32from Products.CMFCore.utils import getToolByName
33from Products.MailHost.MailHost import MailHostError
34from Products.Archetypes.Marshall import Marshaller
35from Products.CMFCore.permissions import View, AccessContentsInformation, \
36     ModifyPortalContent, ReviewPortalContent, DeleteObjects
37import Products.PloneMeeting
38from Products.PloneMeeting.config import *
39from Products.PloneMeeting import PloneMeetingError
40from Products.PloneMeeting.interfaces import *
41import logging
42logger = logging.getLogger('PloneMeeting')
43
44# PloneMeetingError-related constants ------------------------------------------
45WRONG_INTERFACE_NAME = 'Wrong interface name "%s". You must specify the full ' \
46                       'interface package name.'
47WRONG_INTERFACE_PACKAGE = 'Could not find package "%s".'
48WRONG_INTERFACE = 'Interface "%s" not found in package "%s".'
49
50# ------------------------------------------------------------------------------
51monthsIds = {1:  'month_jan', 2:  'month_feb', 3:  'month_mar', 4:  'month_apr',
52             5:  'month_may', 6:  'month_jun', 7:  'month_jul', 8:  'month_aug',
53             9:  'month_sep', 10: 'month_oct', 11: 'month_nov', 12: 'month_dec'}
54
55weekdaysIds = {0: 'weekday_sun', 1: 'weekday_mon', 2: 'weekday_tue',
56               3: 'weekday_wed', 4: 'weekday_thu', 5: 'weekday_fri',
57               6:'weekday_sat'}
58
59adaptables = {
60    'MeetingItem' : {'method':'getItem', 'interface':IMeetingItemCustom},
61    'Meeting' : {'method':'getMeeting', 'interface':IMeetingCustom},
62    # No (condition or action) workflow-related adapters are defined for the
63    # following content types; only a Custom adapter.
64    'MeetingCategory': {'method':None, 'interface':IMeetingCategoryCustom},
65    'ExternalApplication':
66        {'method':None, 'interface':IExternalApplicationCustom},
67    'MeetingConfig': {'method':None, 'interface':IMeetingConfigCustom},
68    'MeetingFile': {'method':None, 'interface':IMeetingFileCustom},
69    'MeetingFileType': {'method':None, 'interface':IMeetingFileTypeCustom},
70    'MeetingGroup': {'method':None, 'interface':IMeetingGroupCustom},
71    'PodTemplate': {'method':None, 'interface':IPodTemplateCustom},
72    'ToolPloneMeeting': {'method':None, 'interface':IToolPloneMeetingCustom},
73    'MeetingUser': {'method':None, 'interface':IMeetingUserCustom},
74}
75
76def getInterface(interfaceName):
77    '''Gets the interface named p_interfaceName.'''
78    elems = interfaceName.split('.')
79    if len(elems) < 2:
80        raise PloneMeetingError(WRONG_INTERFACE_NAME % interfaceName)
81    interfaceName = elems[len(elems)-1]
82    packageName = ''
83    for elem in elems[:-1]:
84        if not packageName:
85            point = ''
86        else:
87            point = '.'
88        packageName += '%s%s' % (point, elem)
89    try:
90        exec 'import %s' % packageName
91        exec 'res = %s.%s' % (packageName, interfaceName)
92        return res
93    except ImportError:
94        raise PloneMeetingError(WRONG_INTERFACE_PACKAGE % packageName)
95    except AttributeError:
96        raise PloneMeetingError(WRONG_INTERFACE % (interfaceName, packageName))
97
98def getWorkflowAdapter(obj, conditions):
99    '''Gets the adapter, for a PloneMeeting object that proposes methods that
100       may be used as workflow conditions (if p_conditions is True) or actions
101       (if p_condition is False).'''
102    tool = getToolByName(obj, TOOL_ID)
103    meetingConfig = tool.getMeetingConfig(obj)
104    interfaceMethod = adaptables[obj.meta_type]['method']
105    if conditions:
106        interfaceMethod += 'Conditions'
107    else:
108        interfaceMethod += 'Actions'
109    exec 'interfaceLongName = meetingConfig.%sInterface()' % interfaceMethod
110    return getInterface(interfaceLongName)(obj)
111
112def getCustomAdapter(obj):
113    '''Tries to get the custom adapter for a PloneMeeting object. If no adapter
114       is defined, returns the object.'''
115    res = obj
116    theInterface = adaptables[obj.meta_type]['interface']
117    try:
118        res = theInterface(obj)
119    except TypeError:
120        pass
121    return res
122
123methodTypes = ('FSPythonScript', 'FSControllerPythonScript', 'instancemethod')
124def getCurrentMeetingObject(context):
125    '''What is the object currently published by Plone ?'''
126    obj = context.REQUEST.get('PUBLISHED')
127    className = obj.__class__.__name__
128    if not (className in ('Meeting', 'MeetingItem')):
129        if className in methodTypes:
130            # We are changing the state of an element. We must then check the
131            # referer
132            refererUrl = context.REQUEST.get('HTTP_REFERER')
133            referer = urlparse.urlparse(refererUrl)[2]
134            if referer.endswith('_view') or referer.endswith('_edit'):
135                referer = os.path.dirname(referer)
136            # We add the portal path if necessary
137            # (in case Apache rewrites the uri for example)
138            portal_path = context.portal_url.getPortalPath()
139            if not referer.startswith(portal_path):
140                # The rewrite rule has modified the URL. First, remove any
141                # added URL prefix.
142                if referer.find('/Members/') != -1:
143                    referer = referer[referer.index('/Members/'):]
144                # Then, add the real portal as URL prefix.
145                referer = portal_path + referer
146            res = context.portal_catalog(path=referer)
147            if res:
148                obj = res[0].getObject()
149        else:
150            # Check the parent (if it has sense)
151            if hasattr(obj, 'getParentNode'):
152                obj = obj.getParentNode()
153                if not (obj.__class__.__name__ in ('Meeting', 'MeetingItem')):
154                    obj = None
155            else:
156                # It can be a method with attribute im_class
157                obj = None
158    return obj
159
160def getOsTempFolder():
161    tmp = '/tmp'
162    if os.path.exists(tmp) and os.path.isdir(tmp):
163        res = tmp
164    elif os.environ.has_key('TMP'):
165        res = os.environ['TMP']
166    elif os.environ.has_key('TEMP'):
167        res = os.environ['TEMP']
168    else:
169        raise "Sorry, I can't find a temp folder on your machine."
170    return res
171
172# How to know if a Kupu field is "empty" ---------------------------------------
173KUPU_EMPTY_VALUES = ('<p></p>', '<p> </p>', '<p><br/></p>', '<p><br /></p>',
174                     '<p><br /><br /></p>', '<p> </p>', '<p><b> </b></p>',
175                     '<p><b></b></p>', '<p/>', '<p />', '<p>&nbsp;</p>')
176                    # The 2 '<p> </p>' are different (2 different blank chars)
177KEEP_WITH_NEXT_STYLES = {'para': 'pmParaKeepWithNext',
178                         'item': 'podItemKeepWithNext'}
179
180def kupuFieldIsEmpty(kupuContent):
181    if not kupuContent or (kupuContent.strip() in KUPU_EMPTY_VALUES):
182        return True
183
184def fieldIsEmpty(name, obj, useParamValue=False, value=None):
185    '''If field named p_name on p_obj empty ? The method checks emptyness of
186       given p_value if p_useParamValue is True instead.'''
187    field = obj.getField(name)
188    if useParamValue:
189        value = value
190    else:
191        value = field.get(obj)
192    widgetName = field.widget.getName()
193    if widgetName == 'RichWidget':
194        return kupuFieldIsEmpty(value)
195    elif widgetName == 'BooleanWidget':
196        return value == None
197    else:
198        return not value
199
200def kupuEquals(fieldContent, valueFromDomParsing):
201    '''Is the content of Kupu field p_fieldContent equal to
202       p_valueFromDomString? The latter is not a str but unicode;
203       differences like <br/> and <br />  and some whitespace are
204       ignored.'''
205    v1 = fieldContent.strip().replace('<br />', '<br/>')
206    v2 = valueFromDomParsing.encode('utf-8')
207    if (v1 == '<p></p>') and (v2 == '<p/>'):
208        res = True
209    else:
210        res = (v1 == v2)
211    return res
212
213def checkPermission(permission, obj):
214    '''We must call getSecurityManager() each time we need to check a
215       permission.'''
216    sm = getSecurityManager()
217    return sm.checkPermission(permission, obj)
218
219# Mail sending machinery -------------------------------------------------------
220class EmailError(Exception): pass
221SENDMAIL_ERROR = 'Error while sending mail: %s.'
222ENCODING_ERROR = 'Encoding error while sending mail: %s.'
223MAILHOST_ERROR = 'Error with the MailServer while sending mail: %s.'
224
225def _getEmailAddress(name, email, encoding='utf-8'):
226    '''Creates a full email address from a p_name and p_email.'''
227    res = email
228    if name: res = name.decode(encoding) + ' <%s>' % email
229    return res.encode(encoding)
230
231def _sendMail(obj, body, recipients, fromAddress, subject, format,
232              attachments=None):
233    '''Sends a mail. p_mto can be a single email or a list of emails.'''
234    # Hide the whole list of recipients if we must send the mail to many.
235    bcc = None
236    if not isinstance(recipients, basestring):
237        bcc = recipients
238        recipients = fromAddress
239    # Construct the data structures for the attachments if relevant
240    if attachments:
241        msg = MIMEMultipart()
242        msg.attach( MIMEText(body) )
243        body = msg
244        for fileName, fileContent in attachments:
245            part = MIMEBase('application', 'octet-stream')
246            if hasattr(fileContent, 'data'):
247                # It is a File instance coming from the DB (frozen doc)
248                payLoad = ''
249                data = fileContent.data
250                while data is not None:
251                    payLoad += data.data
252                    data = data.next
253            else:
254                payLoad = fileContent
255            part.set_payload(payLoad)
256            Encoders.encode_base64(part)
257            part.add_header('Content-Disposition',
258                            'attachment; filename="%s"' % fileName)
259            body.attach(part)
260    try:
261        obj.MailHost.secureSend(body, recipients, fromAddress, subject,mbcc=bcc,
262                                subtype=format, charset='utf-8')
263    except socket.error, sg:
264        raise EmailError(SENDMAIL_ERROR % str(sg))
265    except UnicodeDecodeError, ue:
266        raise EmailError(ENCODING_ERROR % str(ue))
267    except MailHostError, mhe:
268        raise EmailError(MAILHOST_ERROR % str(mhe))
269    except Exception, e:
270        raise EmailError(SENDMAIL_ERROR % str(e))
271
272def sendMail(recipients, obj, event, attachments=None, mapping={}):
273    '''Sends a mail related to p_event that occurred on p_obj to
274       p_recipients. If p_recipients is None, the mail is sent to
275       the system administrator.'''
276    # Do not sent any mail if mail mode is "deactivated".
277    tool = obj.portal_plonemeeting
278    cfg = tool.getMeetingConfig(obj) or tool.getActiveConfigs()[0]
279    mailMode = cfg.getMailMode()
280    if mailMode == 'deactivated': return
281    enc = obj.portal_properties.site_properties.getProperty('default_charset')
282    # Compute user name
283    pms = obj.portal_membership
284    userName = pms.getAuthenticatedMember().id
285    userInfo = pms.getMemberById(userName)
286    if userInfo.getProperty('fullname'):
287        userName = userInfo.getProperty('fullname').decode(enc)
288    # Compute list of MeetingGroups for this user
289    userGroups = ', '.join([g.Title() for g in tool.getGroups()])
290    # Create the message parts
291    d = 'PloneMeeting'
292    portal = obj.portal_url.getPortalObject()
293    portalUrl = tool.getPublicUrl().strip()
294    if not portalUrl: portalUrl = portal.absolute_url()
295    if mapping: translationMapping = mapping
296    else: translationMapping = {}
297    translationMapping.update({
298        'portalUrl': portalUrl, 'portalTitle': portal.Title().decode(enc),
299        'objectTitle': obj.Title().decode(enc), 'objectUrl': obj.absolute_url(),
300        'meetingTitle': '', 'itemTitle': '', 'user': userName,
301        'objectDavUrl': obj.absolute_url_path(), 'groups': userGroups,
302    })
303    if obj.meta_type == 'Meeting':
304        translationMapping['meetingTitle'] = obj.Title().decode(enc)
305    elif obj.meta_type == 'MeetingItem':
306        translationMapping['itemTitle'] = obj.Title().decode(enc)
307        translationMapping['lastAnnexTitle'] = ''
308        translationMapping['lastAnnexTypeTitle'] = ''
309        lastAnnex = obj.getLastInsertedAnnex()
310        if lastAnnex:
311            translationMapping['lastAnnexTitle'] = lastAnnex.Title().decode(enc)
312            translationMapping['lastAnnexTypeTitle'] = \
313                lastAnnex.getMeetingFileType().Title().decode(enc)
314        meeting = obj.getMeeting(brain=True)
315        if meeting:
316            translationMapping['meetingTitle'] = meeting.Title().decode(enc)
317            translationMapping['itemNumber'] = obj.getItemNumber(
318                relativeTo='meeting')
319    # Update the translationMapping with a sub-product-specific
320    # translationMapping, that may also define custom mail subject and body.
321    customRes = obj.adapted().getSpecificMailContext(event, translationMapping)
322    if customRes:
323        subject = customRes[0].encode(enc)
324        body = customRes[1].encode(enc)
325    else:
326        _ = obj.translate
327        subjectLabel = '%s_mail_subject' % event
328        subject = _(subjectLabel, domain=d, mapping=translationMapping)
329        subject = subject.encode(enc)
330        bodyLabel = '%s_mail_body' % event
331        body = _(bodyLabel, domain=d, mapping=translationMapping).encode(enc)
332    adminFromAddress = _getEmailAddress(
333        portal.getProperty('email_from_name'),
334        portal.getProperty('email_from_address'), enc)
335    fromAddress = adminFromAddress
336    if tool.getFunctionalAdminEmail():
337        fromAddress = _getEmailAddress(tool.getFunctionalAdminName(),
338                                       tool.getFunctionalAdminEmail(), enc)
339    if not recipients: recipients = [adminFromAddress]
340    if mailMode == 'test':
341        # Instead of sending mail, in test mode, we log data about the mailing.
342        logger.info('Test mode / we should send mail to %s' % str(recipients))
343        logger.info('Subject is [%s]' % subject)
344        logger.info('Body is [%s]' % body)
345        return
346    # Determine mail format (plain text or HTML)
347    mailFormat = 'plain'
348    if cfg.getUserParam('mailFormat') == 'html': mailFormat = 'html'
349    # Send the mail(s)
350    try:
351        if not attachments:
352            # Send a personalized email for every user.
353            for recipient in recipients:
354                _sendMail(obj, body, recipient, fromAddress, subject,mailFormat)
355        else:
356            # Send a single mail with everybody in bcc, for performance reasons
357            # (avoid to duplicate the attached file(s)).
358            _sendMail(obj, body, recipients, fromAddress, subject, mailFormat,
359                      attachments)
360    except EmailError, ee:
361        logger.warn(str(ee))
362
363def sendMailIfRelevant(obj, event, permissionOrRole, isRole=False,
364                       customEvent=False, mapping={}):
365    '''An p_event just occurred on meeting or item p_obj. If the corresponding
366       meeting config specifies that a mail needs to be sent, this function
367       will send a mail. The mail subject and body are defined from i18n labels
368       that derive from the event name. if p_isRole is True, p_permissionOrRole
369       is a role, and the mail will be sent to every user having this role. If
370       p_isRole is False, p_permissionOrRole is a permission and the mail will
371       be sent to everyone having this permission.  Some mapping can be received
372       and used afterward in mail subject and mail body translations.
373
374       If mail sending is activated (or in test mode) and enabled for this
375       event, this method returns True.
376
377       A plug-in may use this method for sending custom events that are not
378       defined in the MeetingConfig. In this case, you must specify
379       p_customEvent = True.'''
380    res = False
381    tool = obj.portal_plonemeeting
382    # If p_isRole is True and the current user has this role, I will not send
383    # mail: a MeetingManager is already notified!
384    currentUser = obj.portal_membership.getAuthenticatedMember()
385    if isRole and currentUser.has_role(permissionOrRole, obj):
386        # In this case we don't know if mail is enabled or disabled; we just
387        # decide to avoid sending the mail.
388        return
389    cfg = tool.getMeetingConfig(obj)
390    # Do not send the mail if mail mode is "deactivated".
391    if cfg.getMailMode() == 'deactivated': return
392    # Do not send mail if the (not custom) event is unknown.
393    if not customEvent and event not in cfg.getMailItemEvents() and \
394       event not in cfg.getMailMeetingEvents():
395        return
396    # Ok, send a mail. Who are the recipients ?
397    enc = obj.portal_properties.site_properties.getProperty('default_charset')
398    recipients = []
399    adap = obj.adapted()
400    if isRole and (permissionOrRole == 'Owner'):
401        userIds = [obj.Creator()]
402    else:
403        userIds = obj.portal_membership.listMemberIds()
404        # When using the LDAP plugin, this method does not return all
405        # users, nor the cached users!
406    for userId in userIds:
407        user = obj.acl_users.getUser(userId)
408        if not user: continue
409        # May happen if someone deletes the user directly in the ZMI.
410        userInfo = obj.portal_membership.getMemberById(userId)
411        if not userInfo.getProperty('email'): continue
412        # Does the user have the corresponding permission on p_obj ?
413        if isRole: checkMethod = user.has_role
414        else:      checkMethod = user.has_permission
415        if not checkMethod(permissionOrRole, obj): continue
416        recipient = tool.getMailRecipient(userInfo)
417        # Must we avoid sending mail to this recipient for some custom reason?
418        if not adap.includeMailRecipient(event, userId): continue
419        # Has the user unsubscribed to this event in his preferences ?
420        itemEvents = cfg.getUserParam('mailItemEvents', userId=userId)
421        meetingEvents = cfg.getUserParam('mailMeetingEvents', userId=userId)
422        if (event not in itemEvents) and (event not in meetingEvents): continue
423        # After all, we will add this guy to the list of recipients.
424        recipients.append(recipient)
425    sendMail(recipients, obj, event, mapping=mapping)
426    return True
427
428def sendAdviceToGiveMailIfRelevant(event):
429    '''A transition was fired on an item (in p_event.object). Check here if,
430       in the new item state, advices need to be given, that had not to be given
431       in the previous item state.'''
432    tool = event.object.portal_plonemeeting
433    cfg = tool.getMeetingConfig(event.object)
434    if 'adviceToGive' not in cfg.getMailItemEvents(): return
435    for groupId, adviceInfo in event.object.advices.iteritems():
436        adviceStates = getattr(tool, groupId).getItemAdviceStates(cfg)
437        # Ignore advices that must not be given in the current item state
438        if event.new_state.id not in adviceStates: continue
439        # Ignore advices for which already needed to be given in the previous
440        # item state
441        if event.old_state.id in adviceStates: continue
442        # Send a mail to every person from group _advisers.
443        ploneGroup = event.object.acl_users.getGroup('%s_advisers' % groupId)
444        for memberId in ploneGroup.getMemberIds():
445            if 'adviceToGive' not in cfg.getUserParam('mailItemEvents',
446                                                      userId=memberId):
447                continue
448            # Send a mail to this guy
449            recipient = tool.getMailRecipient(memberId)
450            if recipient:
451                labelType = adviceInfo['optional'] and \
452                            'advice_optional' or 'advice_mandatory'
453                type = tool.translate(labelType, domain='PloneMeeting').lower()
454                sendMail([recipient], event.object, 'adviceToGive',
455                         mapping = {'type': type})
456
457# ------------------------------------------------------------------------------
458def marshallBrain(res, value):
459    '''Custom function for producing the XML version of a brain.'''
460    w = res.write
461    # Dump a brain.
462    w('<Title>'); w(cgi.escape(value.Title)); w('</Title>')
463    w('<url>'); w(value.getURL()); w('</url>')
464    w('<uid>'); w(value.UID); w('</uid>')
465    w('<path>'); w(value.getPath()); w('</path>')
466    w('<Creator>'); w(value.Creator); w('</Creator>')
467    w('<review_state>'); w(value.review_state); w('</review_state>')
468    w('<created type="DateTime">'); w(value.created); w('</created>')
469    w('<modified type="DateTime">'); w(value.modified); w('</modified>')
470    if value.meta_type == 'Meeting':
471        # We must also include the meeting date
472        dateIndex = value.portal_catalog.Indexes['getDate']
473        meetingDate = dateIndex.getEntryForObject(value.getRID())
474        w('<Date type="DateTime">'); w(meetingDate); w('</Date>')
475    elif value.meta_type == 'MeetingItem':
476        w('<Title2>'); w(value.getTitle2); w('</Title2>')
477
478marshallParams = {'conversionFunctions': {'mybrains': marshallBrain},
479                  'objectType': 'archetype'}
480
481class HubSessionsMarshaller(Marshaller, XmlMarshaller):
482    '''Abstract marshaller used as base class for marshalling PloneMeeting
483       objects (meetings, items, configs,...).'''
484    marshallContentType = 'text/xml; charset="utf-8"'
485    frozableTypes = ('Meeting', 'MeetingItem')
486    workflowableTypes = ('Meeting', 'MeetingItem')
487
488    def demarshall(self, instance, data, **kwargs):
489        raise 'Unmarshalling is not implemented yet!'
490
491    def marshall(self, instance, **kwargs):
492        '''Produces a XML version of p_instance.'''
493        action = instance.REQUEST.get('do', None)
494        if action:
495            # In this case, we are not requested to produce the XML version of
496            # p_instance; we need to call a method named "action" and return the
497            # XML version of the method result.
498            try:
499                instance.restrictedTraverse(action)
500                methodRes = getattr(instance, action)()
501                res = XmlMarshaller.marshall(self, methodRes, **marshallParams)
502            except Unauthorized, u:
503                res = XmlMarshaller.marshall(self, str(u))
504            except AttributeError, ae:
505                res = XmlMarshaller.marshall(self, 'Attribute error: '+str(ae))
506        else:
507            res = XmlMarshaller.marshall(self, instance, **marshallParams)
508        return (self.marshallContentType, len(res), res)
509
510    def marshallSpecificElements(self, instance, res):
511        '''Marshalls URLs of documents that were generated in the DB from
512           p_instance and a given POD template.'''
513        if instance.meta_type in self.frozableTypes:
514            mConfig = instance.portal_plonemeeting.getMeetingConfig(instance)
515            podTemplatesFolder = getattr(mConfig, TOOL_FOLDER_POD_TEMPLATES)
516            res.write('<frozenDocuments type="list">')
517            for podTemplate in podTemplatesFolder.objectValues():
518                objectFolder = instance.getParentNode()
519                docId = podTemplate.getDocumentId(instance)
520                if hasattr(objectFolder.aq_base, docId) and \
521                   podTemplate.isApplicable(instance):
522                    docObject = getattr(objectFolder, docId)
523                    res.write('<doc type="object">')
524                    self.dumpField(res, 'id', docId)
525                    self.dumpField(res, 'title', docObject.Title())
526                    self.dumpField(res, 'templateId', podTemplate.id)
527                    self.dumpField(
528                        res, 'templateFormat', podTemplate.getPodFormat())
529                    self.dumpField(res, 'data', docObject, fieldType='file')
530                    res.write('</doc>')
531            res.write('</frozenDocuments>')
532        wft = instance.portal_workflow
533        workflows = wft.getWorkflowsFor(instance)
534        if instance.meta_type in self.workflowableTypes:
535            # Dump workflow history
536            res.write('<workflowHistory type="list">')
537            if workflows:
538                history = instance.workflow_history[workflows[0].id]
539                for event in history:
540                    res.write('<event type="object">')
541                    for k, v in event.iteritems(): self.dumpField(res, k, v)
542                    res.write('</event>')
543            res.write('</workflowHistory>')
544        if workflows and (workflows[0].id == 'plonemeeting_activity_workflow'):
545            # Add the object state
546            objectState = wft.getInfoFor(instance, 'review_state')
547            self.dumpField(res, 'active', objectState == 'active')
548
549# ------------------------------------------------------------------------------
550def addRecurringItemsIfRelevant(meeting, transition):
551    '''Sees in the meeting config linked to p_meeting if the triggering of
552       p_transition must lead to the insertion of some recurring items in
553       p_meeting.'''
554    recItems = []
555    meetingConfig = meeting.portal_plonemeeting.getMeetingConfig(meeting)
556    for item in meetingConfig.getItems(usage=('as_recurring_item')):
557        if item.getMeetingTransitionInsertingMe() == transition:
558            recItems.append(item)
559    if recItems:
560        meeting.addRecurringItems(recItems)
561
562# ------------------------------------------------------------------------------
563defaultPermissions = (View, AccessContentsInformation, ModifyPortalContent,
564                      DeleteObjects)
565# I wanted to put permission "ReviewPortalContent" among defaultPermissions,
566# but if I do this, it generates an error when calling "manage_permission" in
567# method "clonePermissions" (see below). I've noticed that in several
568# PloneMeeting standard workflows (meeting_workflow, meetingitem_workflow,
569# meetingfile_workflow, etc), although this permission is declared as a
570# managed permission, when you go in the ZMI to consult the actual
571# permissions that are set on objects governed by those workflows, the
572# permission "Review portal content" does not appear in the list at all.
573
574def clonePermissions(srcObj, destObj, permissions=defaultPermissions):
575    '''This method applies on p_destObj the same values for p_permissions
576       than those that apply for p_srcObj, according to workflow on
577       p_srcObj. p_srcObj may be an item or a meeting.'''
578    wfTool = srcObj.portal_workflow
579    srcWorkflows = wfTool.getWorkflowsFor(srcObj)
580    if not srcWorkflows: return
581    srcWorkflow = srcWorkflows[0]
582    for permission in permissions:
583        if permission in srcWorkflow.permissions:
584            # Get the roles this permission is given to for srcObj in its
585            # current state.
586            srcStateDef = getattr(srcWorkflow.states, srcObj.queryState())
587            permissionInfo = srcStateDef.getPermissionInfo(permission)
588            destObj.manage_permission(permission, permissionInfo['roles'],
589                acquire=permissionInfo['acquired'])
590    # Reindex object because permissions are catalogued.
591    destObj.reindexObject(idxs=['allowedRolesAndUsers'])
592
593# ------------------------------------------------------------------------------
594coreFieldNames = ('id', 'title', 'description')
595def getCustomSchemaFields(baseSchema, completedSchema, cols):
596    '''The Archetypes schema of any PloneMeeting content type can be extended
597       through the "pm_updates.py mechanism". This function returns the list of
598       fields that have been added by a sub-product by checking differences
599       between the p_baseSchema and the p_completedSchema.'''
600    baseFieldNames = baseSchema._fields
601    res = []
602    for field in completedSchema.fields():
603        fieldName = field.getName()
604        if fieldName.endswith('2'): continue
605        if (fieldName not in coreFieldNames) and \
606           (fieldName not in baseFieldNames) and \
607           (field.schemata != 'metadata'):
608            res.append(field)
609    if cols and (cols > 1):
610        # I need to group fields in sub-lists (cols is the number of fields by
611        # sublist).
612        newRes = []
613        row = []
614        for elem in res:
615            if len(row) == cols:
616                newRes.append(row)
617                row = []
618            row.append(elem)
619        # Complete the last unfinished line if required.
620        if row:
621            while len(row) < cols: row.append(None)
622            newRes.append(row)
623        res = newRes
624    return res
625
626# ------------------------------------------------------------------------------
627def allowManagerToCreateIn(folder):
628    '''Allows me (Manager) to create meeting and items in p_folder.'''
629    folder.manage_permission(ADD_CONTENT_PERMISSIONS['MeetingItem'],
630        ('Manager', 'MeetingMember',), acquire=0)
631    folder.manage_permission(ADD_CONTENT_PERMISSIONS['Meeting'],
632        ('Manager', 'MeetingManager',), acquire=0)
633
634def disallowManagerToCreateIn(folder):
635    '''Disallows me (Manager) to create meeting and items in p_folder.'''
636    folder.manage_permission(ADD_CONTENT_PERMISSIONS['MeetingItem'],
637        ('MeetingMember',), acquire=0)
638    folder.manage_permission(ADD_CONTENT_PERMISSIONS['Meeting'],
639        ('MeetingManager',), acquire=0)
640
641# ------------------------------------------------------------------------------
642def getDateFromRequest(day, month, year, start):
643    '''This method produces a DateTime instance from info coming from a request.
644       p_hour and p_month may be ommitted. p_start is a bool indicating if the
645       date will be used as start date or end date; this will allow us to know
646       how to fill p_hour and p_month if they are ommitted. If _year is
647       ommitted, we will return a date near the Big bang (if p_start is True)
648       or near the Apocalypse (if p_start is False). p_day, p_month and p_year
649       are required to be valid string representations of integers.'''
650    # Determine day
651    if not day.strip() or (day == '00'):
652        if start: day = 1
653        else: day = 30
654    else: day = int(day)
655    # Determine month
656    if not month.strip() or (month == '00'):
657        if start: month = 1
658        else: month = 12
659    else: month = int(month)
660    if (month == 2) and (day == 30): day = 28
661    # Determine year
662    if not year.strip() or (year == '0000'):
663        if start: year=1980
664        else: year=3000
665    else:
666        year = int(year)
667    try:
668        res = DateTime('%d/%d/%d' % (month, day, year))
669    except DateTime.DateError:
670        # The date entered by the user is invalid. Take a default date.
671        if start: res = DateTime('1980/01/01')
672        else:     res = DateTime('3000/12/31')
673    return res
674
675# ------------------------------------------------------------------------------
676def getDateFromDelta(aDate, delta):
677    '''This function returns a DateTime instance, which is computed from a
678       reference DateTime instance p_aDate to which a p_delta is applied.
679       A p_delta is a string having the form '<deltaDays>-<hour>:<minutes>,
680       where:
681        - 'deltaDays' is a positive or negative integer indicating the number of
682          days to add/remove;
683        - 'hour' and 'minutes' is the hour and minutes to set for the
684          computed date. It means that the hour and minutes of p_aDate are
685          ignored.
686    '''
687    days, hour = delta.split('.')
688    return DateTime('%s %s' % ((aDate + int(days)).strftime('%Y/%m/%d'), hour))
689
690# ------------------------------------------------------------------------------
691from AccessControl import ClassSecurityInfo
692from App.class_init import InitializeClass
693
694class FakeMeetingUser:
695    '''Used as a replacement for a MeetingUser, ie:
696       * when the real MeetingUser has been deleted,
697       * when we need to get replacement-related info concatenated from several
698         MeetingUser instances.
699    '''
700    security = ClassSecurityInfo()
701    def __init__(self, id, user=None, replacement=None):
702        self.id = id
703        if not user: return
704        bilingual = user.getField('duty2')
705        # MeetingUser instance p_user is replaced by a p_replacement user.
706        self.title = user.Title()
707        if bilingual: self.title2 = user.getTitle2()
708        self.duty = replacement.getReplacementDuty()
709        if bilingual: self.duty2 = replacement.getReplacementDuty2()
710        # If this person replaces another one, self.duty above is the duty of
711        # the person as replacement for the other one. We keep its original
712        # duty in self.originalDuty below.
713        self.originalDuty = user.getDuty()
714        if bilingual: self.originalDuty2 = user.getDuty2()
715
716    security.declarePublic('getId')
717    def getId(self): return self.id
718
719    security.declarePublic('Title')
720    def Title(self): return getattr(self, 'title', '')
721
722    security.declarePublic('getDuty')
723    def getDuty(self, original=False):
724        if not original: return getattr(self, 'duty', '')
725        return getattr(self, 'originalDuty', '')
726
727    security.declarePublic('getBilingual')
728    def getBilingual(self, name, force=1, sep='-'):
729        '''Gets the bilingual content of field named p_name (mimics
730           MeetingUser.getBilingual).'''
731        if force == 1: return getattr(self, name, '')
732        elif force == 2: return getattr(self, name+'2', '')
733        elif force == 'all':
734            return '%s%s%s' % (getattr(self, name, ''), sep,
735                               getattr(self, name+'2', ''))
736InitializeClass(FakeMeetingUser)
737
738def getMeetingUsers(obj, fieldName, theObjects=False, includeDeleted=True,
739                    meetingForRepls=None):
740    '''Gets the meeting users defined on a given p_obj (item or meeting) within
741       a given p_fieldName. Here's the meaning of the remaining params:
742       * theObjects  If True, the method will return MeetingUser instances
743                     instead of MeetingUser IDs (False value is used for
744                     Archetypes getters.
745       * includeDeleted  (works only when p_theObjects is True)
746                     If True, the method will return a FakeMeetingUser
747                     instance for every MeetingUser that has been deleted.
748       * meetingForRepls (works only when p_theObjects is True)
749                     If given, it is a Meeting instance; it means that we
750                     need to take care of user replacements as defined on
751                     this meeting. In this case we will return
752                     a FakeMeetingUser instance for every replaced user, whose
753                     "duty" will be initialized with the replacement duty of
754                     the original user.'''
755    res = obj.getField(fieldName).get(obj)
756    if not theObjects: return res
757    cfg = obj.portal_plonemeeting.getMeetingConfig(obj)
758    newRes = []
759    for id in res:
760        mUser = getattr(cfg.meetingusers, id, None)
761        if not mUser:
762            if includeDeleted: newRes.append(FakeMeetingUser(id))
763        else:
764            if not meetingForRepls: # Simply add the MeetingUser to the result
765                newRes.append(mUser)
766            else:
767                newRes.append(mUser.getForUseIn(meetingForRepls))
768    return newRes
769
770# ------------------------------------------------------------------------------
771class NightWork:
772    '''This class represents a task to perform by night, executed by method
773       tool.nightlife.'''
774    def __init__(self, action, type, params={}):
775        # If p_type is 'notification', this night work represents some task to
776        # achieve after a notification has been received by an external system.
777        # In this case, p_action is the name of the received event; method
778        # "tool.adapted().onNotify" will be called (see interfaces.py). If
779        # p_type is 'method', the night work consists in calling a method whose
780        # "path" in Zope is given in p_action, relative to the Plone site.
781        # For example, if p_action is "tool_plonemeeting.someextapp.sayHello",
782        # method "sayHello" will be called on the external application whose id
783        # is "someextapp" in the HS tool.
784        self.action = action
785        # As explained above, 2 types of nightworks can exist: events and
786        # method calls.
787        self.type = type
788        # One can specify, as a dict, parameters to give to the called method
789        # or onNotify.
790        self.params = params
791
792    def perform(self, tool):
793        '''Performs the nightwork.'''
794        p = self.params
795        w = logger.warn
796        w('Executing nightwork...')
797        if self.type == 'notification':
798            w('=> tool.onNotify("%s", "%s")...' % (p['objectUrl'],p['event']))
799            tool.adapted().onNotify(p['objectUrl'], p['event'])
800        elif self.type == 'method':
801            w('=> %s... Params: %s' % (self.action, self.params))
802            exec 'tool.getParentNode().%s(**p)' % self.action
803        w('Nightwork done.')
804
805    def matches(self, kw):
806        '''Returns True if this nightWork matches criteria in dict p_kw.'''
807        res = True
808        for name, value in kw.iteritems():
809            if name in self.__dict__:
810                res = res and (getattr(self, name) == value)
811            elif self.params and (name in self.params):
812                res = res and (self.params.get(name) == value)
813            else: return
814        return res
815
816# ------------------------------------------------------------------------------
817mainTypes = ('MeetingItem', 'Meeting', 'MeetingFile')
818def getFieldContent(obj, name, force=None, sep='-'):
819    '''Returns the content of p_field on p_obj. If content if available in
820       2 languages, return the one that corresponds to user language, excepted
821       if p_force is integer 1 or 2: in this case it returns content in language
822       1 or 2. If p_force is "all", it returns the content in both languages,
823       separated with p_sep.'''
824    global mainTypes
825    if force:
826        if force == 1: return obj.getField(name).get(obj)
827        elif force == 2: return obj.getField(name+'2').get(obj)
828        elif force == 'all':
829            return '%s%s%s' % (obj.getField(name).get(obj), sep,
830                               obj.getField(name+'2').get(obj))
831    field = obj.getField(name)
832    # Is content of this field bilingual?
833    tool = obj.portal_plonemeeting
834    adaptations = tool.getModelAdaptations()
835    if obj.meta_type in mainTypes:
836        bilingual = 'secondLanguage' in adaptations
837    else:
838        bilingual = 'secondLanguageCfg' in adaptations
839    if not bilingual: return field.get(obj)
840    else:
841        # Get the name of the 2 languages
842        languages = tool.getAvailableInterfaceLanguages().split(',')[:2]
843        userLanguage = tool.getUserLanguage()
844        if (userLanguage not in languages) or (userLanguage == languages[0]):
845            return field.get(obj)
846        else:
847            return obj.getField(field.getName()+'2').get(obj)
848
849def getFieldVersion(obj, name, changes):
850    '''Returns the content of field p_name on p_obj. If p_changes is True,
851       historical modifications of field content are highlighted.'''
852    lastVersion = obj.getField(name).get(obj)
853    if not changes: return lastVersion
854    # Return cumulative diff between successive versions of field
855    res = None
856    lastEvent = None
857    for event in obj.workflow_history[obj.getWorkflowName()]:
858        if (event['action'] == '_datachange_') and (name in event['changes']):
859            if res == None:
860                # We have found the first version of the field
861                res = event['changes'][name]
862            else:
863                # We need to produce the difference between current result and
864                # this version.
865                iMsg, dMsg = getHistoryTexts(obj, lastEvent)
866                comparator = HtmlDiff(res, event['changes'][name], iMsg, dMsg)
867                res = comparator.get()
868            lastEvent = event
869    # Now we need to compare the result with the current version.
870    iMsg, dMsg = getHistoryTexts(obj, lastEvent)
871    comparator = HtmlDiff(res, lastVersion, iMsg, dMsg)
872    return comparator.get()
873
874# ------------------------------------------------------------------------------
875def getLastEvent(obj, transition, notBefore='transfer'):
876    '''Returns, from the workflow history of p_obj, the event that corresponds
877       to the most recent triggering of p_transition (=its name). p_transition
878       can be a list of names: in this case, it returns the event about the most
879       recently triggered transition (ie, accept, refuse or delay). If
880       p_notBefore is given, it corresponds to a kind of start transition for
881       the search: we will not search in the history preceding the last
882       triggering of this transition. This is useful when history of an item
883       is the combined history of this item from several sites, and we want
884       to search only within history of the "last" site, so we want to ignore
885       everything that occurrred before the last "transfer" transition.'''
886    history = obj.workflow_history[obj.getWorkflowName()]
887    i = len(history)-1
888    while i >= 0:
889        event = history[i]
890        if notBefore and (event['action'] == notBefore): return
891        if isinstance(transition, basestring):
892            condition = event['action'] == transition
893        else:
894            condition = event['action'] in transition
895        if condition: return event
896        i -= 1
897
898# ------------------------------------------------------------------------------
899# History-related functions
900# ------------------------------------------------------------------------------
901def rememberPreviousData(obj, name=None):
902    '''This method is called before updating p_obj and remembers, for every
903       historized field (or only for p_name if explicitly given), the previous
904       value. Result is a dict ~{s_fieldName: previousFieldValue}~'''
905    res = {}
906    cfg = obj.portal_plonemeeting.getMeetingConfig(obj)
907    isItem = obj.meta_type == 'MeetingItem'
908    # Do nothing if the object is not in a state when historization is enabled.
909    if isItem: meth = cfg.getRecordItemHistoryStates
910    else:      meth = cfg.getRecordMeetingHistoryStates
911    if obj.queryState() not in meth(): return res
912    # Store in res the values currently stored on p_obj.
913    if isItem: historized = cfg.getHistorizedItemAttributes()
914    else:      historized = cfg.getHistorizedMeetingAttributes()
915    if name:
916        if name in historized: res[name] = obj.getField(name).get(obj)
917    else:
918        for name in historized: res[name] = obj.getField(name).get(obj)
919    return res
920
921def addDataChange(obj, previousData=None):
922    '''This method adds a "data change" event in the object history. If the
923       previous data are not given in p_previousData, we look for it in
924       obj._v_previousData.'''
925    if previousData == None:
926        previousData = getattr(obj, '_v_previousData', None)
927    if not previousData: return
928    # Remove from p_previousData values that were not changed or that were empty
929    for name in previousData.keys():
930        field = obj.getField(name)
931        oldValue = previousData[name]
932        if isinstance(oldValue, basestring): oldValue = oldValue.strip()
933        newValue = field.get(obj)
934        if isinstance(newValue, basestring): newValue = newValue.strip()
935        if oldValue == newValue:
936            del previousData[name]
937    if not previousData: return
938    # Add an event in the history
939    userId = obj.portal_membership.getAuthenticatedMember().getId()
940    event = {'action': '_datachange_', 'actor': userId, 'time': DateTime(),
941             'comments': '', 'review_state': obj.queryState(),
942             'changes': previousData}
943    if hasattr(obj, '_v_previousData'): del obj._v_previousData
944    # Add the event to the history
945    obj.workflow_history[obj.getWorkflowName()] += (event,)
946
947def hasHistory(obj, fieldName=None):
948    '''Has p_obj an history? If p_fieldName is specified, the question is: has
949       p_obj an history for field p_fieldName?'''
950    wfName = obj.getWorkflowName()
951    if hasattr(obj.aq_base, 'workflow_history') and obj.workflow_history and \
952       (wfName in obj.workflow_history):
953        for event in obj.workflow_history[wfName]:
954            if not fieldName:
955                condition = event['action']
956            else:
957                condition = (event['action'] == '_datachange_') and \
958                            (fieldName in event['changes'])
959            if condition: return True
960
961def findNewValue(obj, name, history, stopIndex):
962    '''This function tries to find a more recent version of value of field
963       p_name on p_obj. It first tries to find it in history[:stopIndex+1]. If
964       it does not find it there, it returns the current value on p_obj.'''
965    i = stopIndex+1
966    while (i-1) >= 0:
967        i -= 1
968        if history[i]['action'] != '_datachange_': continue
969        if name not in history[i]['changes']: continue
970        # We have found it!
971        return history[i]['changes'][name]
972    return obj.getField(name).get(obj)
973
974def getHistoryTexts(obj, event):
975    '''Returns a tuple (insertText, deleteText) containing texts to show on,
976       respectively, inserted and deleted chunks of text.'''
977    userName = obj.portal_plonemeeting.getUserName(event['actor'])
978    mapping = {'userName': userName.decode('utf-8')}
979    res = []
980    for type in ('insert', 'delete'):
981        msg = obj.translate('history_%s' % type, mapping=mapping,
982                            domain='PloneMeeting')
983        date = obj.portal_plonemeeting.formatDate(event['time'],
984                                                  short=True, withHour=True)
985        msg = '%s: %s' % (date, msg)
986        res.append(msg.encode('utf-8'))
987    return res
988
989def getHistory(obj, startNumber=0, batchSize=5):
990    '''Returns the history for this object, sorted in reverse order (most
991       recent change first) if p_reverse is True.'''
992    res = []
993    history = list(obj.workflow_history[obj.getWorkflowName()][1:])
994    history.reverse()
995    stopIndex = startNumber + batchSize - 1
996    i = -1
997    while (i+1) < len(history):
998        i += 1
999        # Keep only events in range startNumber:startNumber+batchSize
1000        if i < startNumber: continue
1001        if i > stopIndex: break
1002        event = history[i]
1003        if event['action'] == '_datachange_':
1004            # We take a copy, because we will modify it.
1005            event = history[i].copy()
1006            event['changes'] = {}
1007            for name, oldValue in history[i]['changes'].iteritems():
1008                widgetName = obj.getField(name).widget.getName()
1009                if widgetName == 'RichWidget':
1010                    if kupuFieldIsEmpty(oldValue):
1011                        val = '-'
1012                    else:
1013                        newValue = findNewValue(obj, name, history, i-1)
1014                        # Compute the diff between oldValue and newValue
1015                        iMsg, dMsg = getHistoryTexts(obj, event)
1016                        comparator = HtmlDiff(oldValue, newValue, iMsg, dMsg)
1017                        val = comparator.get()
1018                    event['changes'][name] = val
1019                elif widgetName == 'BooleanWidget':
1020                    label = oldValue and 'Yes' or 'No'
1021                    event['changes'][name] = obj.translate(label)
1022                elif widgetName == 'TextAreaWidget':
1023                    val = oldValue.replace('\r', '').replace('\n', '<br/>')
1024                    event['changes'][name] = val
1025                elif widgetName == 'SelectionWidget':
1026                    allValues = getattr(obj, obj.getField(name).vocabulary)()
1027                    val = allValues.getValue(oldValue or '')
1028                    event['changes'][name] = val or '-'
1029                elif widgetName == 'MultiSelectionWidget':
1030                    allValues = getattr(obj, obj.getField(name).vocabulary)()
1031                    val = [allValues.getValue(v) for v in oldValue]
1032                    if not val: val = '-'
1033                    else: val = '<br/>'.join(val)
1034                    event['changes'][name] = val
1035                else:
1036                    event['changes'][name] = oldValue
1037        res.append(event)
1038    return {'events': res, 'totalNumber': len(history)}
1039
1040# ------------------------------------------------------------------------------
1041def setFieldFromAjax(obj, fieldName, newValue):
1042    '''Sets on p_obj the content of a field whose name is p_fieldName and whose
1043       new value is p_fieldValue. This method is called by Ajax pages.'''
1044    field = obj.getField(fieldName)
1045    # Keep old value, we might need to historize it.
1046    previousData = rememberPreviousData(obj, fieldName)
1047    field.set(obj, newValue)
1048    # Potentially store it in object history
1049    if previousData: addDataChange(obj, previousData)
1050    # Update the last modification date
1051    obj.modification_date = DateTime()
1052    # Apply XHTML transforms when relevant
1053    obj.transformAllRichTextFields(onlyField=fieldName)
1054    obj.reindexObject()
1055
1056# ------------------------------------------------------------------------------
1057class ZCTextIndexInfo:
1058    '''Silly class used for storing information about a ZCTextIndex.'''
1059    lexicon_id = "plone_lexicon"
1060    index_type = 'Okapi BM25 Rank'
1061
1062def updateIndexes(ploneSite, indexInfo, logger):
1063    '''This method creates or updates, in a p_ploneSite, definitions of indexes
1064       in its portal_catalog, based on index-related information given in
1065       p_indexInfo. p_indexInfo is a dictionary of the form
1066       {s_indexName:s_indexType}. Here are some examples of index types:
1067       "FieldIndex", "ZCTextIndex", "DateIndex".'''
1068    catalog = ploneSite.portal_catalog
1069    zopeCatalog = catalog._catalog
1070    for indexName, indexType in indexInfo.iteritems():
1071        # If this index already exists but with a different type, remove it.
1072        if (indexName in zopeCatalog.indexes):
1073            oldType = zopeCatalog.indexes[indexName].__class__.__name__
1074            if oldType != indexType:
1075                catalog.delIndex(indexName)
1076                logger.info('Existing index "%s" of type "%s" was removed:'\
1077                            ' we need to recreate it with type "%s".' % \
1078                            (indexName, oldType, indexType))
1079        if indexName not in zopeCatalog.indexes:
1080            # We need to create this index
1081            if indexType != 'ZCTextIndex':
1082                catalog.addIndex(indexName, indexType)
1083            else:
1084                catalog.addIndex(indexName,indexType,extra=ZCTextIndexInfo)
1085            # Indexing database content based on this index.
1086            catalog.reindexIndex(indexName, ploneSite.REQUEST)
1087            logger.info('Created index "%s" of type "%s"...' % \
1088                        (indexName, indexType))
1089
1090# ------------------------------------------------------------------------------
1091tokens = ( ('<li', -1), ('</li>', 5))
1092crlf = ('\r', '\n')
1093def formatXhtmlFieldForAppy(value):
1094    '''p_value is a string containing XHTML code. This code must follow some
1095       rules to be Appy-compliant (ie, for appy.shared.XhtmlDiff to work
1096       properly).'''
1097    global tokens
1098    global crlf
1099    for token, delta in tokens:
1100        res = []
1101        i = 0 # Where I am in p_value
1102        while i < len(value):
1103            j = value.find(token, i)
1104            if j == -1:
1105                # No more occurrences. Dump the end of p_value in the result.
1106                res.append(value[i:])
1107                break
1108            res.append(value[i:j])
1109            deltaj = j+delta
1110            if (delta < 0) and (j > 0) and (value[deltaj] not in crlf):
1111                res.append('\n')
1112            i = j + len(token)
1113            res.append(value[j:i])
1114            if (delta > 0) and (j > 0) and (value[deltaj] not in crlf):
1115                res.append('\n')
1116        value = ''.join(res)
1117    return value
1118# ------------------------------------------------------------------------------
Note: See TracBrowser for help on using the repository browser.