diff --git a/bin/new.py b/bin/new.py index 01b2893..321f59c 100755 --- a/bin/new.py +++ b/bin/new.py @@ -27,14 +27,16 @@ WRONG_INSTANCE_PATH = '"%s" must be an existing folder for creating the ' \ class NewScript: '''usage: %prog ploneVersion plonePath instancePath - "ploneVersion" can be plone25 or plone3 + "ploneVersion" can be plone25, plone30, or plone3x + (plone3x can be Plone 3.2.x, Plone 3.3.5...) "plonePath" is the (absolute) path to you plone installation. - Plone 2.5 is typically installed in /opt/Plone-2.5.5, - while Plone 3 is typically installed in /usr/local/Plone. + Plone 2.5 and 3.0 are typically installed in + /opt/Plone-x.x.x, while Plone 3 > 3.0 is typically + installed in in /usr/local/Plone. "instancePath" is the (absolute) path where you want to create your instance (should not already exist).''' - ploneVersions = ('plone25', 'plone3') + ploneVersions = ('plone25', 'plone30', 'plone3x') def createInstance(self, linksForProducts): '''Calls the Zope script that allows to create a Zope instance and copy @@ -65,35 +67,44 @@ class NewScript: print cmd os.system(cmd) # Now, make the instance Plone-ready - productsFolder = os.path.join(self.instancePath, 'Products') - libFolder = os.path.join(self.instancePath, 'lib/python') - print 'Copying Plone stuff in the Zope instance...' - if self.ploneVersion == 'plone25': - self.installPlone25Stuff(productsFolder,libFolder,linksForProducts) - elif self.ploneVersion == 'plone3': - self.installPlone3Stuff(productsFolder, libFolder) + action = 'Copying' + if linksForProducts: + action = 'Symlinking' + print '%s Plone stuff in the Zope instance...' % action + if self.ploneVersion in ('plone25', 'plone30'): + self.installPlone25or30Stuff(linksForProducts) + elif self.ploneVersion == 'plone3x': + self.installPlone3Stuff() # Clean the copied folders - cleanFolder(productsFolder) - cleanFolder(libFolder) + cleanFolder(os.path.join(self.instancePath, 'Products')) + cleanFolder(os.path.join(self.instancePath, 'lib/python')) - def installPlone25Stuff(self, productsFolder, libFolder, linksForProducts): + def installPlone25or30Stuff(self, linksForProducts): '''Here, we will copy all Plone2-related stuff in the Zope instance we've created, to get a full Plone-ready Zope instance. If p_linksForProducts is True, we do not perform a real copy: we will create symlinks to products lying within Plone installer files.''' - installerProducts = os.path.join(self.plonePath, 'zeocluster/Products') - for name in os.listdir(installerProducts): - folderName = os.path.join(installerProducts, name) - if os.path.isdir(folderName): - destFolder = os.path.join(productsFolder, name) - # This is a Plone product. Copy it to the instance. - if (self.ploneVersion == 'plone25') and linksForProducts: - # Create a symlink to this product in the instance - cmd = 'ln -s %s %s' % (folderName, destFolder) - os.system(cmd) - else: - # Copy thre product into the instance - shutil.copytree(folderName, destFolder) + j = os.path.join + if self.ploneVersion == 'plone25': + sourceFolders = ('zeocluster/Products',) + else: + sourceFolders = ('zinstance/Products', 'zinstance/lib/python') + for sourceFolder in sourceFolders: + sourceBase = j(self.plonePath, sourceFolder) + destBase = j(self.instancePath, + sourceFolder[sourceFolder.find('/')+1:]) + for name in os.listdir(sourceBase): + folderName = j(sourceBase, name) + if os.path.isdir(folderName): + destFolder = j(destBase, name) + # This is a Plone product. Copy it to the instance. + if linksForProducts: + # Create a symlink to this product in the instance + cmd = 'ln -s %s %s' % (folderName, destFolder) + os.system(cmd) + else: + # Copy thre product into the instance + shutil.copytree(folderName, destFolder) uglyChunks = ('pkg_resources', '.declare_namespace(') def findPythonPackageInEgg(self, currentFolder): @@ -168,7 +179,7 @@ class NewScript: f.write(fileContent) f.close() - def installPlone3Stuff(self, productsFolder, libFolder): + def installPlone3Stuff(self): '''Here, we will copy all Plone3-related stuff in the Zope instance we've created, to get a full Plone-ready Zope instance.''' # All Plone 3 eggs are in buildout-cache/eggs. We will extract from @@ -179,14 +190,16 @@ class NewScript: # /lib/python (ie, like Appy applications) # - Zope products that will be copied in # /Products (ie, like Appy generated Zope products) - eggsFolder = os.path.join(self.plonePath, 'buildout-cache/eggs') + j = os.path.join + eggsFolder = j(self.plonePath, 'buildout-cache/eggs') + productsFolder = j(self.instancePath, 'Products') + libFolder = j(self.instancePath, 'lib/python') for name in os.listdir(eggsFolder): - eggMainFolder = os.path.join(eggsFolder, name) + eggMainFolder = j(eggsFolder, name) if name.startswith('Products.'): # A Zope product. Copy its content in Products. innerFolder= self.getSubFolder(self.getSubFolder(eggMainFolder)) - destFolder = os.path.join(productsFolder, - os.path.basename(innerFolder)) + destFolder = j(productsFolder, os.path.basename(innerFolder)) shutil.copytree(innerFolder, destFolder) else: # A standard Python package. Copy its content in lib/python. @@ -197,7 +210,7 @@ class NewScript: # Copy those files directly in libFolder. for fileName in os.listdir(eggMainFolder): if fileName.endswith('.py'): - fullFileName= os.path.join(eggMainFolder, fileName) + fullFileName= j(eggMainFolder, fileName) shutil.copy(fullFileName, libFolder) continue eggFolderName = os.path.basename(eggFolder) @@ -205,8 +218,7 @@ class NewScript: # Goddamned. This should go in productsFolder and not in # libFolder. innerFolder = self.getSubFolder(eggFolder) - destFolder = os.path.join(productsFolder, - os.path.basename(innerFolder)) + destFolder = j(productsFolder,os.path.basename(innerFolder)) shutil.copytree(innerFolder, destFolder) else: packageFolder = self.findPythonPackageInEgg(eggFolder) @@ -225,23 +237,22 @@ class NewScript: # before copying the Python package. baseFolder = libFolder for subFolder in destFolders: - subFolderPath=os.path.join(baseFolder,subFolder) + subFolderPath = j(baseFolder,subFolder) if not os.path.exists(subFolderPath): os.mkdir(subFolderPath) # Create an empty __init__.py in it. - init = os.path.join(subFolderPath,'__init__.py') + init = j(subFolderPath,'__init__.py') f = file(init, 'w') f.write('# Makes me a Python package.') f.close() baseFolder = subFolderPath destFolder = os.sep.join(destFolders) - destFolder = os.path.join(libFolder, destFolder) + destFolder = j(libFolder, destFolder) if not os.path.exists(destFolder): os.makedirs(destFolder) else: destFolder = libFolder - destFolder = os.path.join( - destFolder, os.path.basename(packageFolder)) + destFolder = j(destFolder, os.path.basename(packageFolder)) shutil.copytree(packageFolder, destFolder) self.patchPlone(productsFolder, libFolder) @@ -264,11 +275,12 @@ class NewScript: def run(self): optParser = OptionParser(usage=NewScript.__doc__) optParser.add_option("-l", "--links", action="store_true", - help="[Linux, plone25 only] Within the created instance, symlinks "\ - "to Products lying within the Plone installer files are " \ - "created instead of copying them into the instance. This " \ - "avoids duplicating the Products source code and is " \ - "interesting if you create a lot of Zope instances.") + help="[Linux, plone25 or plone30 only] Within the created " \ + "instance, symlinks to Products lying within the Plone " \ + "installer files are created instead of copying them into " \ + "the instance. This avoids duplicating the Products source " \ + "code and is interesting if you create a lot of Zope " \ + "instances.") (options, args) = optParser.parse_args() linksForProducts = options.links try: diff --git a/gen/__init__.py b/gen/__init__.py index 2e842b7..dee5fe1 100644 --- a/gen/__init__.py +++ b/gen/__init__.py @@ -306,9 +306,20 @@ class Type: self.searchable = searchable # Normally, permissions to read or write every attribute in a type are # granted if the user has the global permission to read or - # create/edit instances of the whole type. If you want a given attribute + # edit instances of the whole type. If you want a given attribute # to be protected by specific permissions, set one or the 2 next boolean - # values to "True". + # values to "True". In this case, you will create a new "field-only" + # read and/or write permission. If you need to protect several fields + # with the same read/write permission, you can avoid defining one + # specific permission for every field by specifying a "named" + # permission (string) instead of assigning "True" to the following + # arg(s). A named permission will be global to your whole Zope site, so + # take care to the naming convention. Typically, a named permission is + # of the form: ": Write|Read xxx". If, for example, I want + # to define, for my application "MedicalFolder" a specific permission + # for a bunch of fields that can only be modified by a doctor, I can + # define a permission "MedicalFolder: Write medical information" and + # assign it to the "specificWritePermission" of every impacted field. self.specificReadPermission = specificReadPermission self.specificWritePermission = specificWritePermission # Widget width and height @@ -383,12 +394,18 @@ class Type: self.descrId = self.labelId + '_descr' self.helpId = self.labelId + '_help' # Determine read and write permissions for this field - if self.specificReadPermission: + rp = self.specificReadPermission + if rp and not isinstance(rp, basestring): self.readPermission = '%s: Read %s %s' % (appName, prefix, name) + elif rp and isinstance(rp, basestring): + self.readPermission = rp else: self.readPermission = 'View' - if self.specificWritePermission: + wp = self.specificWritePermission + if wp and not isinstance(wp, basestring): self.writePermission = '%s: Write %s %s' % (appName, prefix, name) + elif wp and isinstance(wp, basestring): + self.writePermission = wp else: self.writePermission = 'Modify portal content' if isinstance(self, Ref): @@ -508,7 +525,12 @@ class Type: value = getattr(obj, self.name, None) if (value == None): # If there is no value, get the default value if any - if not self.editDefault: return self.default + if not self.editDefault: + # Return self.default, of self.default() if it is a method + if type(self.default) == types.FunctionType: + return self.default(obj.appy()) + else: + return self.default # If value is editable, get the default value from the flavour portalTypeName = obj._appy_getPortalType(obj.REQUEST) tool = obj.getTool() @@ -577,14 +599,14 @@ class Type: return obj.translate('%s_valid' % self.labelId) elif type(self.validator) == validatorTypes[1]: # It is a regular expression - if not validator.match(value): + if not self.validator.match(value): # If the regular expression is among the default ones, we # generate a specific error message. - if validator == String.EMAIL: + if self.validator == String.EMAIL: return obj.translate('bad_email') - elif validator == String.URL: + elif self.validator == String.URL: return obj.translate('bad_url') - elif validator == String.ALPHANUMERIC: + elif self.validator == String.ALPHANUMERIC: return obj.translate('bad_alphanumeric') else: return obj.translate('%s_valid' % self.labelId) @@ -771,19 +793,20 @@ class String(Type): # CSS property: "none" (default), "uppercase", "capitalize" or # "lowercase". self.transform = transform - # Default width and height vary according to String format - if width == None: - if format == String.TEXT: width = 60 - else: width = 30 - if height == None: - if format == String.TEXT: height = 5 - else: height = 1 Type.__init__(self, validator, multiplicity, index, default, optional, editDefault, show, page, group, layouts, move, indexed, searchable, specificReadPermission, specificWritePermission, width, height, colspan, master, masterValue, focus, historized) self.isSelect = self.isSelection() + # Default width and height vary according to String format + if width == None: + if format == String.TEXT: self.width = 60 + else: self.width = 30 + if height == None: + if format == String.TEXT: self.height = 5 + elif self.isSelect: self.height = 4 + else: self.height = 1 self.filterable = self.indexed and (self.format == String.LINE) and \ not self.isSelect @@ -1139,6 +1162,7 @@ class Ref(Type): self.validable = self.link def getDefaultLayouts(self): return {'view': 'l-f', 'edit': 'lrv-f'} + def isShowable(self, obj, layoutType): if (layoutType == 'edit') and self.add: return False if self.isBack: @@ -1438,9 +1462,14 @@ class Permission: defining a workflow, for example, you need to use instances of "ReadPermission" and "WritePermission", the 2 children classes of this class. For example, if you need to refer to write permission of - attribute "t1" of class A, write: "WritePermission("A.t1") or + attribute "t1" of class A, write: WritePermission("A.t1") or WritePermission("x.y.A.t1") if class A is not in the same module as - where you instantiate the class.''' + where you instantiate the class. + + Note that this holds only if you use attributes "specificReadPermission" + and "specificWritePermission" as booleans. When defining named + (string) permissions, for referring to it you simply use those strings, + you do not create instances of ReadPermission or WritePermission.''' def __init__(self, fieldDescriptor): self.fieldDescriptor = fieldDescriptor diff --git a/gen/layout.py b/gen/layout.py index ab7ecc2..818ae91 100644 --- a/gen/layout.py +++ b/gen/layout.py @@ -14,7 +14,9 @@ # w - The widgets of the current page/class # n - The navigation panel (inter-objects navigation) # b - The range of buttons (intra-object navigation, save, edit, delete...) -# m - The global status message sometimes shown. +# m - The global status message sometimes shown. If you specify this in a +# layout, ensure that you have hidden the global_statusmessage zone as +# proposed by Plone. Else, the message will appear twice. # Layout elements for a field -------------------------------------------------- # l - "label" The field label diff --git a/gen/plone25/generator.py b/gen/plone25/generator.py index ca370b1..3e6995f 100644 --- a/gen/plone25/generator.py +++ b/gen/plone25/generator.py @@ -156,11 +156,8 @@ class Generator(AbstractGenerator): self.generateWorkflows() self.generateWrappers() self.generateTests() - if self.config.frontPage == True: - self.labels.append(msg('front_page_text', '', msg.FRONT_PAGE_TEXT)) - self.copyFile('frontPage.pt', self.repls, - destFolder=self.skinsFolder, - destName='%sFrontPage.pt' % self.applicationName) + if self.config.frontPage: + self.generateFrontPage() self.copyFile('configure.zcml', self.repls) self.copyFile('import_steps.xml', self.repls, destFolder='profiles/default') @@ -169,8 +166,6 @@ class Generator(AbstractGenerator): self.copyFile('Portlet.pt', self.repls, destName='%s.pt' % self.portletName, destFolder=self.skinsFolder) self.copyFile('tool.gif', {}) - self.copyFile( - 'global_statusmessage.pt', {}, destFolder=self.skinsFolder) self.copyFile('Styles.css.dtml',self.repls, destFolder=self.skinsFolder, destName = '%s.css.dtml' % self.applicationName) self.copyFile('IEFixes.css.dtml',self.repls,destFolder=self.skinsFolder) @@ -418,7 +413,7 @@ class Generator(AbstractGenerator): repls['appClasses'] = "[%s]" % ','.join(appClasses) repls['minimalistPlone'] = self.config.minimalistPlone repls['showPortlet'] = self.config.showPortlet - repls['appFrontPage'] = self.config.frontPage == True + repls['appFrontPage'] = bool(self.config.frontPage) repls['workflows'] = workflows self.copyFile('Install.py', repls, destFolder='Extensions') @@ -576,6 +571,27 @@ class Generator(AbstractGenerator): repls['modulesWithTests'] = ','.join(modules) self.copyFile('testAll.py', repls, destFolder='tests') + def generateFrontPage(self): + fp = self.config.frontPage + repls = self.repls.copy() + if fp == True: + # We need a front page, but no specific one has been given. + # So we will create a basic one that will simply display + # some translated text. + self.labels.append(msg('front_page_text', '', msg.FRONT_PAGE_TEXT)) + repls['pageContent'] = '' + else: + # The user has specified a macro to show. So in the generated front + # page, we will call this macro. The user will need to add itself + # a .pt file containing this macro in the skins folder of the + # generated Plone product. + page, macro = fp.split('/') + repls['pageContent'] = '' % (page, macro) + self.copyFile('frontPage.pt', repls, destFolder=self.skinsFolder, + destName='%sFrontPage.pt' % self.applicationName) + def generateTool(self): '''Generates the Plone tool that corresponds to this application.''' # Generate the tool class in itself and related i18n messages diff --git a/gen/plone25/mixins/ToolMixin.py b/gen/plone25/mixins/ToolMixin.py index 67581a2..0bf79cc 100644 --- a/gen/plone25/mixins/ToolMixin.py +++ b/gen/plone25/mixins/ToolMixin.py @@ -74,8 +74,16 @@ class ToolMixin(AbstractMixin): '''Returns the list of root classes for this application.''' return self.getProductConfig().rootClasses - def showPortlet(self): - return not self.portal_membership.isAnonymousUser() + def showPortlet(self, context): + if self.portal_membership.isAnonymousUser(): return False + if context.id == 'skyn': context = context.getParentNode() + res = True + if not self.getRootClasses(): + res = False + # If there is no root class, show the portlet only if we are within + # the configuration. + if (self.id in context.absolute_url()): res = True + return res def getObject(self, uid, appy=False): '''Allows to retrieve an object from its p_uid.''' @@ -630,7 +638,7 @@ class ToolMixin(AbstractMixin): contentType, flavourNumber = d1.split(':') flavourNumber = int(flavourNumber) searchName = keySuffix = d2 - batchSize = self.getNumberOfResultsPerPage() + batchSize = self.appy().numberOfResultsPerPage if not searchName: keySuffix = contentType s = self.REQUEST.SESSION searchKey = 'search_%s_%s' % (flavourNumber, keySuffix) diff --git a/gen/plone25/mixins/__init__.py b/gen/plone25/mixins/__init__.py index e622093..240a38e 100644 --- a/gen/plone25/mixins/__init__.py +++ b/gen/plone25/mixins/__init__.py @@ -285,23 +285,23 @@ class AbstractMixin: '''Returns the method named p_methodName.''' return getattr(self, methodName, None) - def getFormattedValue(self, name, useParamValue=False, value=None, - forMasterId=False): + def getFieldValue(self, name, useParamValue=False, value=None, + formatted=True): '''Returns the value of field named p_name for this object (p_self). If p_useParamValue is True, the method uses p_value instead of the real field value (useful for rendering a value from the object history, for example). - If p_forMasterId is True, it returns the value as will be needed to - produce an identifier used within HTML pages for master/slave - relationships.''' + If p_formatted is False, it will return the true database + (or default) value. Else, it will produce a nice, string and + potentially translated value.''' appyType = self.getAppyType(name) # Which value will we use ? if not useParamValue: value = appyType.getValue(self) # Return the value as is if it is None or forMasterId - if forMasterId: return value + if not formatted: return value # Return the formatted value else return appyType.getFormattedValue(self, value) @@ -741,7 +741,7 @@ class AbstractMixin: res = brains return res - def fieldValueSelected(self, fieldName, vocabValue): + def fieldValueSelected(self, fieldName, vocabValue, dbValue): '''When displaying a selection box (ie a String with a validator being a list), must the _vocabValue appear as selected?''' rq = self.REQUEST @@ -749,14 +749,14 @@ class AbstractMixin: if rq.has_key(fieldName): compValue = rq.get(fieldName) else: - compValue = self.getAppyType(fieldName).getValue(self) + compValue = dbValue # Compare the value if type(compValue) in sequenceTypes: if vocabValue in compValue: return True else: if vocabValue == compValue: return True - def checkboxChecked(self, fieldName): + def checkboxChecked(self, fieldName, dbValue): '''When displaying a checkbox, must it be checked or not?''' rq = self.REQUEST # Get the value we must compare (from request or from database) @@ -764,11 +764,11 @@ class AbstractMixin: compValue = rq.get(fieldName) compValue = compValue in ('True', 1, '1') else: - compValue = self.getAppyType(fieldName).getValue(self) + compValue = dbValue # Compare the value return compValue - def dateValueSelected(self, fieldName, fieldPart, dateValue): + def dateValueSelected(self, fieldName, fieldPart, dateValue, dbValue): '''When displaying a date field, must the particular p_dateValue be selected in the field corresponding to the date part?''' # Get the value we must compare (from request or from database) @@ -779,7 +779,7 @@ class AbstractMixin: if compValue.isdigit(): compValue = int(compValue) else: - compValue = self.getAppyType(fieldName).getValue(self) + compValue = dbValue if compValue: compValue = getattr(compValue, fieldPart)() # Compare the value diff --git a/gen/plone25/skin/page.pt b/gen/plone25/skin/page.pt index 1ec4c9e..ae2708f 100644 --- a/gen/plone25/skin/page.pt +++ b/gen/plone25/skin/page.pt @@ -455,7 +455,7 @@ - diff --git a/gen/plone25/skin/portlet.pt b/gen/plone25/skin/portlet.pt index 8a0d6be..59e84c5 100644 --- a/gen/plone25/skin/portlet.pt +++ b/gen/plone25/skin/portlet.pt @@ -13,10 +13,10 @@
- - @@ -159,7 +159,7 @@
+ tal:attributes="class python: (len(phases) > 1) and ('appyPhase step%s' % phase['phaseStatus']) or 'appyPhase'">   diff --git a/gen/plone25/skin/widgets/date.pt b/gen/plone25/skin/widgets/date.pt index bd174be..c67856b 100644 --- a/gen/plone25/skin/widgets/date.pt +++ b/gen/plone25/skin/widgets/date.pt @@ -15,7 +15,7 @@ @@ -28,7 +28,7 @@ @@ -39,7 +39,7 @@ The icon for displaying the date chooser @@ -53,7 +53,7 @@ : @@ -64,7 +64,7 @@ diff --git a/gen/plone25/skin/widgets/show.pt b/gen/plone25/skin/widgets/show.pt index 68e445d..09213cf 100644 --- a/gen/plone25/skin/widgets/show.pt +++ b/gen/plone25/skin/widgets/show.pt @@ -54,7 +54,8 @@ tal:define="contextMacro python: portal.skyn.widgets; layout python: widget['layouts'][layoutType]; name widget/name; - value python: contextObj.getFormattedValue(name); + value python: contextObj.getFieldValue(name); + rawValue python: contextObj.getFieldValue(name, formatted=False); requestValue python: request.get(name, None); inRequest python: request.has_key(name); errors errors | python: (); diff --git a/gen/plone25/skin/widgets/string.pt b/gen/plone25/skin/widgets/string.pt index 516a0e6..cb37708 100644 --- a/gen/plone25/skin/widgets/string.pt +++ b/gen/plone25/skin/widgets/string.pt @@ -4,8 +4,7 @@ maxMult python: widget['multiplicity'][1]; severalValues python: (maxMult == None) or (maxMult > 1)"> + tal:attributes="class widget/master_css; id rawValue">
@@ -29,15 +28,17 @@ isOneLine python: fmt in (0,3)"> - @@ -83,7 +84,7 @@ validator defines a list of values, with a "AND/OR" checkbox. The "and" / "or" radio buttons - diff --git a/gen/plone25/templates/Portlet.pt b/gen/plone25/templates/Portlet.pt index 86e9154..f94e215 100644 --- a/gen/plone25/templates/Portlet.pt +++ b/gen/plone25/templates/Portlet.pt @@ -5,7 +5,7 @@
+ tal:condition="python: tool.showPortlet(context)">
(not this either :) */ +textarea { width: 99%; } + #portal-breadcrumbs { display: none; } #importedElem { color: grey; font-style: italic; } label { font-weight: bold; font-style: italic; } diff --git a/gen/plone25/templates/frontPage.pt b/gen/plone25/templates/frontPage.pt index 25cda4a..ec7a5ed 100644 --- a/gen/plone25/templates/frontPage.pt +++ b/gen/plone25/templates/frontPage.pt @@ -1,15 +1,11 @@ - -
- -
-
- + -
- +
+
diff --git a/gen/plone25/templates/global_statusmessage.pt b/gen/plone25/templates/global_statusmessage.pt deleted file mode 100644 index e38289c..0000000 --- a/gen/plone25/templates/global_statusmessage.pt +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/gen/po.py b/gen/po.py index 42209dd..e91eed0 100644 --- a/gen/po.py +++ b/gen/po.py @@ -248,10 +248,10 @@ class PoFile: keepExistingOrder=True): '''Updates the existing messages with p_newMessages. If p_removeNotNewMessages is True, all messages in self.messages - that are not in newMessages will be removed. If p_keepExistingOrder - is False, self.messages will be sorted according to p_newMessages. - Else, newMessages that are not yet in self.messages will be appended - to the end of self.messages.''' + that are not in newMessages will be removed, excepted if they start + with "custom_". If p_keepExistingOrder is False, self.messages will + be sorted according to p_newMessages. Else, newMessages that are not + yet in self.messages will be appended to the end of self.messages.''' # First, remove not new messages if necessary newIds = [m.id for m in newMessages] removedIds = [] @@ -259,7 +259,7 @@ class PoFile: i = len(self.messages)-1 while i >= 0: oldId = self.messages[i].id - if oldId not in newIds: + if not oldId.startswith('custom_') and (oldId not in newIds): del self.messages[i] del self.messagesDict[oldId] removedIds.append(oldId)