2 # -*- coding: utf-8 -*-
4 # Copyright 2004-2006 Zuza Software Foundation
6 # This file is part of translate.
8 # translate is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 2 of the License, or
11 # (at your option) any later version.
13 # translate is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with translate; if not, write to the Free Software
20 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
22 """manages a translation file and its associated files"""
24 from translate
.storage
import base
25 from translate
.storage
import po
26 from translate
.storage
.poheader
import tzstring
27 from translate
.storage
import xliff
28 from translate
.storage
import factory
29 from translate
.filters
import checks
30 from translate
.misc
.multistring
import multistring
31 from Pootle
import __version__
32 from Pootle
import statistics
33 from jToolkit
import timecache
34 from jToolkit
import glock
38 _UNIT_CHECKER
= checks
.UnitChecker()
41 """locked interaction with a filesystem file"""
42 #Locking is disabled for now since it impacts performance negatively and was
43 #not complete yet anyway. Reverse svn revision 5271 to regain the locking
45 def __init__(self
, filename
):
46 self
.filename
= filename
50 self
.lock
= glock
.GlobalLock(self
.filename
+ os
.extsep
+ "lock")
56 def readmodtime(self
):
57 """returns the modification time of the file (locked operation)"""
58 return statistics
.getmodtime(self
.filename
)
60 def getcontents(self
):
61 """returns modtime, contents tuple (locked operation)"""
62 pomtime
= statistics
.getmodtime(self
.filename
)
63 fp
= open(self
.filename
, 'r')
64 filecontents
= fp
.read()
66 return pomtime
, filecontents
68 def writecontents(self
, contents
):
69 """writes contents to file, returning modification time (locked operation)"""
70 f
= open(self
.filename
, 'w')
73 pomtime
= statistics
.getmodtime(self
.filename
)
77 """this represents the assignments for a file"""
78 def __init__(self
, basefile
):
79 """constructs assignments object for the given file"""
80 # TODO: try and remove circular references between basefile and this class
81 self
.basefile
= basefile
82 self
.assignsfilename
= self
.basefile
.filename
+ os
.extsep
+ "assigns"
86 """reads the assigns if neccessary or returns them from the cache"""
87 if os
.path
.exists(self
.assignsfilename
):
88 self
.assigns
= self
.readassigns()
93 def readassigns(self
):
94 """reads the assigns from the associated assigns file, returning the assigns
95 the format is a number of lines consisting of
96 username: action: itemranges
97 where itemranges is a comma-separated list of item numbers or itemranges like 3-5
98 e.g. pootlewizz: review: 2-99,101"""
99 assignsmtime
= statistics
.getmodtime(self
.assignsfilename
)
100 if assignsmtime
== getattr(self
, "assignsmtime", None):
102 assignsfile
= open(self
.assignsfilename
, "r")
103 assignsstring
= assignsfile
.read()
106 itemcount
= len(getattr(self
, "stats", {}).get("total", []))
107 for line
in assignsstring
.split("\n"):
110 if not line
.count(":") == 2:
111 print "invalid assigns line in %s: %r" % (self
.assignsfilename
, line
)
113 username
, action
, itemranges
= line
.split(":", 2)
114 username
, action
= username
.strip().decode('utf-8'), action
.strip().decode('utf-8')
115 if not username
in poassigns
:
116 poassigns
[username
] = {}
117 userassigns
= poassigns
[username
]
118 if not action
in userassigns
:
119 userassigns
[action
] = []
120 items
= userassigns
[action
]
121 for itemrange
in itemranges
.split(","):
123 if not itemrange
.count("-") == 1:
124 print "invalid assigns range in %s: %r (from line %r)" % (self
.assignsfilename
, itemrange
, line
)
126 itemstart
, itemstop
= [int(item
.strip()) for item
in itemrange
.split("-", 1)]
127 items
.extend(range(itemstart
, itemstop
+1))
129 item
= int(itemrange
.strip())
132 items
= [item
for item
in items
if 0 <= item
< itemcount
]
133 userassigns
[action
] = items
136 def assignto(self
, item
, username
, action
):
137 """assigns the item to the given username for the given action"""
138 userassigns
= self
.assigns
.setdefault(username
, {})
139 items
= userassigns
.setdefault(action
, [])
140 if item
not in items
:
144 def unassign(self
, item
, username
=None, action
=None):
145 """removes assignments of the item to the given username (or all users) for the given action (or all actions)"""
147 usernames
= self
.assigns
.keys()
149 usernames
= [username
]
150 for username
in usernames
:
151 userassigns
= self
.assigns
.setdefault(username
, {})
153 itemlist
= [userassigns
.get(action
, []) for action
in userassigns
]
155 itemlist
= [userassigns
.get(action
, [])]
156 for items
in itemlist
:
161 def saveassigns(self
):
162 """saves the current assigns to file"""
163 # assumes self.assigns is up to date
165 usernames
= self
.assigns
.keys()
167 for username
in usernames
:
168 actions
= self
.assigns
[username
].keys()
170 for action
in actions
:
171 items
= self
.assigns
[username
][action
]
176 assignstring
= "%s: %s: " % (username
.encode('utf-8'), action
.encode('utf-8'))
178 if item
- 1 == lastitem
:
179 if rangestart
is None:
180 rangestart
= lastitem
182 if rangestart
is not None:
183 assignstring
+= "-%d" % lastitem
186 assignstring
+= "%d" % item
188 assignstring
+= ",%d" % item
190 if rangestart
is not None:
191 assignstring
+= "-%d" % lastitem
192 assignstrings
.append(assignstring
+ "\n")
193 assignsfile
= open(self
.assignsfilename
, "w")
194 assignsfile
.writelines(assignstrings
)
197 def getunassigned(self
, action
=None):
198 """gets all strings that are unassigned (for the given action if given)"""
199 unassigneditems
= range(0, self
.basefile
.statistics
.getitemslen())
200 self
.assigns
= self
.getassigns()
201 for username
in self
.assigns
:
202 if action
is not None:
203 assigneditems
= self
.assigns
[username
].get(action
, [])
206 for action
, actionitems
in self
.assigns
[username
].iteritems():
207 assigneditems
+= actionitems
208 unassigneditems
= [item
for item
in unassigneditems
if item
not in assigneditems
]
209 return unassigneditems
211 def finditems(self
, search
):
212 """returns items that match the .assignedto and/or .assignedaction criteria in the searchobject"""
213 # search.assignedto == [None] means assigned to nobody
214 if search
.assignedto
== [None]:
215 assignitems
= self
.getunassigned(search
.assignedaction
)
217 # filter based on assign criteria
218 assigns
= self
.getassigns()
219 if search
.assignedto
:
220 usernames
= [search
.assignedto
]
222 usernames
= assigns
.iterkeys()
224 for username
in usernames
:
225 if search
.assignedaction
:
226 actionitems
= assigns
[username
].get(search
.assignedaction
, [])
227 assignitems
.extend(actionitems
)
229 for actionitems
in assigns
[username
].itervalues():
230 assignitems
.extend(actionitems
)
233 class pootlebase(object):
236 class pootlefile(pootlebase
):
237 """this represents a pootle-managed file and its associated files"""
238 x_generator
= "Pootle %s" % __version__
.ver
239 def __init__(self
, project
=None, pofilename
=None):
241 self
.__class
__.__bases
__ = (factory
.getclass(pofilename
),)
242 super(pootlefile
, self
).__init
__()
243 self
.pofilename
= pofilename
245 from Pootle
import projects
246 self
.project
= projects
.DummyProject(None)
248 self
.filename
= self
.pofilename
250 self
.project
= project
251 self
.checker
= self
.project
.checker
252 self
.filename
= os
.path
.join(self
.project
.podir
, self
.pofilename
)
254 self
.lockedfile
= LockedFile(self
.filename
)
255 # we delay parsing until it is required
259 self
.pendingfilename
= self
.filename
+ os
.extsep
+ "pending"
260 self
.pendingfile
= None
261 self
.statistics
= statistics
.pootlestatistics(self
)
262 self
.tmfilename
= self
.filename
+ os
.extsep
+ "tm"
263 # we delay parsing until it is required
265 self
.tracker
= timecache
.timecache(20*60)
267 def parsestring(cls
, storestring
):
269 newstore
.parse(storestring
)
271 parsestring
= classmethod(parsestring
)
273 def parsefile(cls
, storefile
):
274 """Reads the given file (or opens the given filename) and parses back to an object"""
275 if isinstance(storefile
, basestring
):
276 storefile
= open(storefile
, "r")
277 if "r" in getattr(storefile
, "mode", "r"):
278 storestring
= storefile
.read()
281 return cls
.parsestring(storestring
)
282 parsefile
= classmethod(parsefile
)
284 def getheaderplural(self
):
285 """returns values for nplural and plural values. It tries to see if the
286 file has it specified (in a po header or similar)."""
288 return super(pootlefile
, self
).getheaderplural()
289 except AttributeError:
292 def updateheaderplural(self
, *args
, **kwargs
):
293 """updates the file header. If there is an updateheader function in the
294 underlying store it will be delegated there."""
296 super(pootlefile
, self
).updateheaderplural(*args
, **kwargs
)
297 except AttributeError:
300 def updateheader(self
, **kwargs
):
301 """updates the file header. If there is an updateheader function in the
302 underlying store it will be delegated there."""
304 super(pootlefile
, self
).updateheader(**kwargs
)
305 except AttributeError:
308 def readpendingfile(self
):
309 """reads and parses the pending file corresponding to this file"""
310 if os
.path
.exists(self
.pendingfilename
):
311 inputfile
= open(self
.pendingfilename
, "r")
312 self
.pendingfile
= factory
.getobject(inputfile
, ignore
=".pending")
314 self
.pendingfile
= po
.pofile()
316 def savependingfile(self
):
317 """saves changes to disk..."""
318 output
= str(self
.pendingfile
)
319 outputfile
= open(self
.pendingfilename
, "w")
320 outputfile
.write(output
)
323 def readtmfile(self
):
324 """reads and parses the tm file corresponding to this file"""
325 if os
.path
.exists(self
.tmfilename
):
326 tmmtime
= statistics
.getmodtime(self
.tmfilename
)
327 if tmmtime
== getattr(self
, "tmmtime", None):
329 inputfile
= open(self
.tmfilename
, "r")
330 self
.tmmtime
, self
.tmfile
= tmmtime
, factory
.getobject(inputfile
, ignore
=".tm")
332 self
.tmfile
= po
.pofile()
334 def getsuggestions(self
, item
):
335 """find all the suggestion items submitted for the given item"""
336 unit
= self
.getitem(item
)
337 if isinstance(unit
, xliff
.xliffunit
):
338 return unit
.getalttrans()
340 locations
= unit
.getlocations()
341 self
.readpendingfile()
342 # TODO: review the matching method
343 suggestpos
= [suggestpo
for suggestpo
in self
.pendingfile
.units
if suggestpo
.getlocations() == locations
]
346 def addsuggestion(self
, item
, suggtarget
, username
):
347 """adds a new suggestion for the given item"""
348 unit
= self
.getitem(item
)
349 if isinstance(unit
, xliff
.xliffunit
):
350 if isinstance(suggtarget
, list) and (len(suggtarget
) > 0):
351 suggtarget
= suggtarget
[0]
352 unit
.addalttrans(suggtarget
, origin
=username
)
353 self
.statistics
.reclassifyunit(item
)
357 self
.readpendingfile()
359 if username
is not None:
360 newpo
.msgidcomments
.append('"_: suggested by %s\\n"' % username
)
361 newpo
.target
= suggtarget
362 newpo
.markfuzzy(False)
363 self
.pendingfile
.addunit(newpo
)
364 self
.savependingfile()
365 self
.statistics
.reclassifyunit(item
)
367 def deletesuggestion(self
, item
, suggitem
):
368 """removes the suggestion from the pending file"""
369 unit
= self
.getitem(item
)
370 if hasattr(unit
, "xmlelement"):
371 suggestions
= self
.getsuggestions(item
)
372 unit
.delalttrans(suggestions
[suggitem
])
375 self
.readpendingfile()
376 locations
= unit
.getlocations()
377 # TODO: remove the suggestion in a less brutal manner
378 pendingitems
= [pendingitem
for pendingitem
, suggestpo
in enumerate(self
.pendingfile
.units
) if suggestpo
.getlocations() == locations
]
379 pendingitem
= pendingitems
[suggitem
]
380 del self
.pendingfile
.units
[pendingitem
]
381 self
.savependingfile()
382 self
.statistics
.reclassifyunit(item
)
384 def getsuggester(self
, item
, suggitem
):
385 """returns who suggested the given item's suggitem if recorded, else None"""
386 unit
= self
.getsuggestions(item
)[suggitem
]
387 if hasattr(unit
, "xmlelement"):
388 return unit
.xmlelement
.getAttribute("origin")
390 for msgidcomment
in unit
.msgidcomments
:
391 if msgidcomment
.find("suggested by ") != -1:
392 suggestedby
= po
.unquotefrompo([msgidcomment
]).replace("_:", "", 1).replace("suggested by ", "", 1).strip()
396 def gettmsuggestions(self
, item
):
397 """find all the tmsuggestion items submitted for the given item"""
399 unit
= self
.getitem(item
)
400 locations
= unit
.getlocations()
401 # TODO: review the matching method
402 # Can't simply use the location index, because we want multiple matches
403 suggestpos
= [suggestpo
for suggestpo
in self
.tmfile
.units
if suggestpo
.getlocations() == locations
]
406 def track(self
, item
, message
):
407 """sets the tracker message for the given item"""
408 self
.tracker
[item
] = message
410 def readpofile(self
):
411 """reads and parses the main file"""
412 # make sure encoding is reset so it is read from the file
415 pomtime
, filecontents
= self
.lockedfile
.getcontents()
416 # note: we rely on this not resetting the filename, which we set earlier, when given a string
417 self
.parse(filecontents
)
418 self
.pomtime
= pomtime
420 def savepofile(self
):
421 """saves changes to the main file to disk..."""
423 self
.pomtime
= self
.lockedfile
.writecontents(output
)
426 """makes sure we have a freshly parsed pofile"""
428 if self
.pomtime
!= self
.lockedfile
.readmodtime():
431 # If this exception is not triggered by a bad
432 # symlink, then we have a missing file on our hands...
433 if not os
.path
.islink(self
.filename
):
434 # ...and thus we rescan our files to get rid of the missing filename
435 self
.project
.scanpofiles()
437 print "%s is a broken symlink" % (self
.filename
,)
440 """returns pofile output"""
442 return super(pootlefile
, self
).getoutput()
444 def updateunit(self
, item
, newvalues
, userprefs
, languageprefs
):
445 """updates a translation with a new target value"""
447 unit
= self
.getitem(item
)
449 if newvalues
.has_key("target"):
450 unit
.target
= newvalues
["target"]
451 if newvalues
.has_key("fuzzy"):
452 unit
.markfuzzy(newvalues
["fuzzy"])
453 if newvalues
.has_key("translator_comments"):
455 if newvalues
["translator_comments"]:
456 unit
.addnote(newvalues
["translator_comments"])
458 po_revision_date
= time
.strftime("%Y-%m-%d %H:%M") + tzstring()
459 headerupdates
= {"PO_Revision_Date": po_revision_date
, "X_Generator": self
.x_generator
}
461 if getattr(userprefs
, "name", None) and getattr(userprefs
, "email", None):
462 headerupdates
["Last_Translator"] = "%s <%s>" % (userprefs
.name
, userprefs
.email
)
463 # XXX: If we needed to add a header, the index value in item will be one out after
465 # TODO: remove once we force the PO class to always output headers
466 force_recache
= False
467 if not self
.header():
469 self
.updateheader(add
=True, **headerupdates
)
471 nplurals
= getattr(languageprefs
, "nplurals", None)
472 pluralequation
= getattr(languageprefs
, "pluralequation", None)
473 if nplurals
and pluralequation
:
474 self
.updateheaderplural(nplurals
, pluralequation
)
477 self
.statistics
.purge_totals()
478 self
.statistics
.reclassifyunit(item
)
480 def getitem(self
, item
):
481 """Returns a single unit based on the item number."""
482 return self
.units
[self
.statistics
.getstats()["total"][item
]]
484 def iteritems(self
, search
, lastitem
=None):
485 """iterates through the items in this pofile starting after the given lastitem, using the given search"""
486 # update stats if required
487 translatables
= self
.statistics
.getstats()["total"]
491 minitem
= lastitem
+ 1
492 maxitem
= len(translatables
)
493 validitems
= range(minitem
, maxitem
)
494 if search
.assignedto
or search
.assignedaction
:
495 assignitems
= self
.getassigns().finditems(search
)
496 validitems
= [item
for item
in validitems
if item
in assignitems
]
497 # loop through, filtering on matchnames if required
498 for item
in validitems
:
499 if not search
.matchnames
:
501 for name
in search
.matchnames
:
502 if translatables
[item
] in self
.statistics
.getstats()[name
]:
505 def matchitems(self
, newfile
, uselocations
=False):
506 """matches up corresponding items in this pofile with the given newfile, and returns tuples of matching poitems (None if no match found)"""
507 if not hasattr(self
, "sourceindex"):
509 if not hasattr(newfile
, "sourceindex"):
512 for newpo
in newfile
.units
:
517 newlocations
= newpo
.getlocations()
519 for location
in newlocations
:
520 if location
in mergedlocations
:
522 if location
in self
.locationindex
:
523 oldpo
= self
.locationindex
[location
]
524 if oldpo
is not None:
526 matches
.append((oldpo
, newpo
))
527 mergedlocations
.append(location
)
530 # We can't use the multistring, because it might contain more than two
531 # entries in a PO xliff file. Rather use the singular.
532 source
= unicode(newpo
.source
)
533 if source
in self
.sourceindex
:
534 oldpo
= self
.sourceindex
[source
]
535 matches
.append((oldpo
, newpo
))
537 matches
.append((None, newpo
))
538 # find items that have been removed
539 matcheditems
= [oldpo
for oldpo
, newpo
in matches
if oldpo
]
540 for oldpo
in self
.units
:
541 if not oldpo
in matcheditems
:
542 matches
.append((oldpo
, None))
545 def getassigns(self
):
546 if self
.assigns
is None:
547 self
.assigns
= pootleassigns(self
)
550 def mergeitem(self
, oldpo
, newpo
, username
, suggest
=False):
551 """merges any changes from newpo into oldpo"""
552 unchanged
= oldpo
.target
== newpo
.target
553 if not suggest
and (not oldpo
.target
or not newpo
.target
or oldpo
.isheader() or newpo
.isheader() or unchanged
):
556 for item
in self
.statistics
.getstats()["total"]:
557 matchpo
= self
.units
[item
]
559 strings
= getattr(newpo
.target
, "strings", [newpo
.target
])
560 self
.addsuggestion(item
, strings
, username
)
562 raise KeyError("Could not find item for merge")
564 def mergefile(self
, newfile
, username
, allownewstrings
=True, suggestions
=False):
565 """make sure each msgid is unique ; merge comments etc from duplicates into original"""
567 matches
= self
.matchitems(newfile
)
568 for oldpo
, newpo
in matches
:
571 self
.mergeitem(oldpo
, newpo
, username
, suggest
=True)
576 if isinstance(newpo
, po
.pounit
):
579 self
.addunit(self
.UnitClass
.buildfromunit(newpo
))
581 # TODO: mark the old one as obsolete
584 self
.mergeitem(oldpo
, newpo
, username
)
585 # we invariably want to get the ids (source locations) from the newpo
586 if hasattr(newpo
, "sourcecomments"):
587 oldpo
.sourcecomments
= newpo
.sourcecomments
589 if not isinstance(newfile
, po
.pofile
) or suggestions
:
590 #TODO: We don't support updating the header yet.
592 # the easiest way to recalculate everything
596 #Let's update selected header entries. Only the ones listed below, and ones
597 #that are empty in self can be updated. The check in header_order is just
598 #a basic sanity check so that people don't insert garbage.
599 updatekeys
= ['Content-Type',
602 'Project-Id-Version',
606 ownheader
= self
.parseheader()
607 for (key
, value
) in newfile
.parseheader().items():
608 if key
in updatekeys
or (not key
in ownheader
or not ownheader
[key
]) and key
in po
.pofile
.header_order
:
609 headerstoaccept
[key
] = value
610 self
.updateheader(add
=True, **headerstoaccept
)
612 #Now update the comments above the header:
613 header
= self
.header()
614 newheader
= newfile
.header()
615 if header
is None and not newheader
is None:
616 header
= self
.UnitClass("", encoding
=self
.encoding
)
619 header
._initallcomments
(blankall
=True)
621 for i
in range(len(header
.allcomments
)):
622 header
.allcomments
[i
].extend(newheader
.allcomments
[i
])
625 # the easiest way to recalculate everything
629 """an object containing all the searching information"""
630 def __init__(self
, dirfilter
=None, matchnames
=[], assignedto
=None, assignedaction
=None, searchtext
=None):
631 self
.dirfilter
= dirfilter
632 self
.matchnames
= matchnames
633 self
.assignedto
= assignedto
634 self
.assignedaction
= assignedaction
635 self
.searchtext
= searchtext
638 """returns a copy of this search"""
639 return Search(self
.dirfilter
, self
.matchnames
, self
.assignedto
, self
.assignedaction
, self
.searchtext
)