/gourmet-0.15.9/src/lib/exporters/exporter.py

# · Python · 653 lines · 617 code · 12 blank · 24 comment · 18 complexity · 8a888c46431c5fd7add926d70acb8b99 MD5 · raw file

  1. import re, Image, os.path, os, xml.sax.saxutils, time, shutil, urllib, textwrap, types
  2. from gourmet import gglobals, convert
  3. from gourmet.gdebug import *
  4. from gettext import gettext as _
  5. from gourmet.plugin_loader import Pluggable, pluggable_method
  6. from gourmet.plugin import BaseExporterPlugin, BaseExporterMultiRecPlugin
  7. from gourmet.threadManager import SuspendableThread
  8. REC_ATTR_DIC = gglobals.REC_ATTR_DIC
  9. DEFAULT_ATTR_ORDER = gglobals.DEFAULT_ATTR_ORDER
  10. DEFAULT_TEXT_ATTR_ORDER = gglobals.DEFAULT_TEXT_ATTR_ORDER
  11. class exporter (SuspendableThread, Pluggable):
  12. """A base exporter class.
  13. All Gourmet exporters should subclass this class or one of its
  14. derivatives.
  15. This class can also be used directly for plain text export.
  16. """
  17. DEFAULT_ENCODING = 'utf-8'
  18. name='exporter'
  19. ALLOW_PLUGINS_TO_WRITE_NEW_FIELDS = True
  20. def __init__ (self, rd, r, out,
  21. conv=None,
  22. imgcount=1,
  23. order=['image','attr','ings','text'],
  24. attr_order=DEFAULT_ATTR_ORDER,
  25. text_attr_order = DEFAULT_TEXT_ATTR_ORDER,
  26. do_markup=True,
  27. use_ml=False,
  28. convert_attnames=True,
  29. fractions=convert.FRACTIONS_ASCII,
  30. ):
  31. """Instantiate our exporter.
  32. conv is a preexisting convert.converter() class
  33. imgcount is a number we use to start counting our exported images.
  34. order is a list of our core elements in order: 'image','attr','text' and 'ings'
  35. attr_order is a list of our attributes in the order we should export them:
  36. title, category, cuisine, servings, source, rating, preptime, cooktime
  37. text_attr_order is a list of our text attributes.
  38. do_markup is a flag; if true, we interpret tags in text blocks by calling
  39. self.handle_markup to e.g. to simple plaintext renderings of tags.
  40. use_ml is a flag; if true, we escape strings we output to be valid *ml
  41. convert_attnames is a flag; if true, we hand write_attr a translated attribute name
  42. suitable for printing or display. If not, we just hand it the standard
  43. attribute name (this is a good idea if a subclass needs to rely on the
  44. attribute name staying consistent for processing, since converting attnames
  45. will produce locale specific strings.
  46. """
  47. self.attr_order=attr_order
  48. self.text_attr_order = text_attr_order
  49. self.out = out
  50. self.r = r
  51. self.rd=rd
  52. self.do_markup=do_markup
  53. self.fractions=fractions
  54. self.use_ml=use_ml
  55. self.convert_attnames = convert_attnames
  56. if not conv: conv=convert.get_converter()
  57. self.conv=conv
  58. self.imgcount=imgcount
  59. self.images = []
  60. self.order = order
  61. Pluggable.__init__(self,[BaseExporterPlugin])
  62. SuspendableThread.__init__(self,self.name)
  63. def do_run (self):
  64. self.write_head()
  65. for task in self.order:
  66. if task=='image':
  67. if self._grab_attr_(self.r,'image'):
  68. self.write_image(self.r.image)
  69. if task=='attr':
  70. self._write_attrs_()
  71. elif task=='text':
  72. self._write_text_()
  73. elif task=='ings': self._write_ings_()
  74. self.write_foot()
  75. # Internal methods -- ideally, subclasses should have no reason to
  76. # override any of these methods.
  77. @pluggable_method
  78. def _write_attrs_ (self):
  79. self.write_attr_head()
  80. for a in self.attr_order:
  81. txt=self._grab_attr_(self.r,a)
  82. debug('_write_attrs_ writing %s=%s'%(a,txt),1)
  83. if txt and (
  84. (type(txt) not in [str,unicode])
  85. or
  86. txt.strip()
  87. ):
  88. if (a=='preptime' or a=='cooktime') and a.find("0 ")==0: pass
  89. else:
  90. if self.convert_attnames:
  91. self.write_attr(REC_ATTR_DIC.get(a,a),txt)
  92. else:
  93. self.write_attr(a,txt)
  94. self.write_attr_foot()
  95. @pluggable_method
  96. def _write_text_ (self):
  97. #print 'exporter._write_text_',self.text_attr_order,'!'
  98. for a in self.text_attr_order:
  99. # This code will never be called for Gourmet
  100. # proper... here for convenience of symbiotic project...
  101. if a=='step':
  102. steps = self._grab_attr_(self.r,a)
  103. if not steps: continue
  104. for s in steps:
  105. if isinstance(s,dict):
  106. dct = s
  107. s = dct.get('text','')
  108. img = dct.get('image','')
  109. time = dct.get('time',0)
  110. #print 'Exporter sees step AS:'
  111. #print ' text:',s
  112. #print ' image:',img
  113. #print ' time:',time
  114. else:
  115. img = ''
  116. if self.do_markup:
  117. txt=self.handle_markup(s)
  118. if not self.use_ml: txt = xml.sax.saxutils.unescape(s)
  119. if self.convert_attnames:
  120. out_a = gglobals.TEXT_ATTR_DIC.get(a,a)
  121. else:
  122. out_a = a
  123. # Goodness this is an ugly way to pass the
  124. # time as a parameter... we use try/except to
  125. # allow all gourmet exporters to ignore this
  126. # attribute.
  127. try: self.write_text(a,s,time=time)
  128. except:
  129. self.write_text(a,s)
  130. print 'Failed to export time=',time
  131. raise
  132. if img:
  133. self.write_image(img)
  134. continue
  135. # End of non-Gourmet code
  136. txt=self._grab_attr_(self.r,a)
  137. if txt and txt.strip():
  138. if self.do_markup: txt=self.handle_markup(txt)
  139. #else: print 'exporter: do_markup=False'
  140. if not self.use_ml: txt = xml.sax.saxutils.unescape(txt)
  141. if self.convert_attnames:
  142. self.write_text(gglobals.TEXT_ATTR_DIC.get(a,a),txt)
  143. else:
  144. self.write_text(a,txt)
  145. @pluggable_method
  146. def _write_ings_ (self):
  147. """Write all of our ingredients.
  148. """
  149. ingredients = self.rd.get_ings(self.r)
  150. if not ingredients:
  151. return
  152. self.write_inghead()
  153. for g,ings in self.rd.order_ings(ingredients):
  154. if g:
  155. self.write_grouphead(g)
  156. for i in ings:
  157. amount,unit = self._get_amount_and_unit_(i)
  158. if self._grab_attr_(i,'refid'):
  159. self.write_ingref(amount=amount,
  160. unit=unit,
  161. item=self._grab_attr_(i,'item'),
  162. refid=self._grab_attr_(i,'refid'),
  163. optional=self._grab_attr_(i,'optional')
  164. )
  165. else:
  166. self.write_ing(amount=amount,
  167. unit=unit,
  168. item=self._grab_attr_(i,'item'),
  169. key=self._grab_attr_(i,'ingkey'),
  170. optional=self._grab_attr_(i,'optional')
  171. )
  172. if g:
  173. self.write_groupfoot()
  174. self.write_ingfoot()
  175. def _grab_attr_ (self, obj, attr):
  176. # This is a bit ugly -- we allow exporting categories as if
  177. # they were a single attribute even though we in fact allow
  178. # multiple categories.
  179. if attr=='category':
  180. return ', '.join(self.rd.get_cats(obj))
  181. try:
  182. ret = getattr(obj,attr)
  183. except:
  184. return None
  185. else:
  186. if attr in ['preptime','cooktime']:
  187. # this 'if' ought to be unnecessary, but is kept around
  188. # for db converting purposes -- e.g. so we can properly
  189. # export an old DB
  190. if ret and type(ret)!=str:
  191. ret = convert.seconds_to_timestring(ret,fractions=self.fractions)
  192. elif attr=='rating' and ret and type(ret)!=str:
  193. if ret/2==ret/2.0:
  194. ret = "%s/5 %s"%(ret/2,_('stars'))
  195. else:
  196. ret = "%s/5 %s"%(ret/2.0,_('stars'))
  197. elif attr=='servings' and type(ret)!=str:
  198. ret = convert.float_to_frac(ret,fractions=self.fractions)
  199. elif attr=='yields':
  200. ret = convert.float_to_frac(ret,fractions=self.fractions)
  201. yield_unit = self._grab_attr_(obj,'yield_unit')
  202. if yield_unit:
  203. ret = '%s %s'%(ret,yield_unit) # FIXME: i18n? (fix also below in exporter_mult)
  204. if type(ret) in [str,unicode] and attr not in ['thumb','image']:
  205. try:
  206. ret = ret.encode(self.DEFAULT_ENCODING)
  207. except:
  208. print "oops:",ret,"doesn't look like unicode."
  209. raise
  210. return ret
  211. def _get_amount_and_unit_ (self, ing):
  212. return self.rd.get_amount_and_unit(ing,fractions=self.fractions)
  213. # Below are the images inherited exporters should
  214. # subclass. Subclasses overriding methods should make these
  215. # pluggable so that plugins can fiddle about with things as they
  216. # see fit.
  217. def write_image (self, image):
  218. """Write image based on binary data for an image file (jpeg format)."""
  219. pass
  220. def write_head (self):
  221. """Write any necessary header material at recipe start."""
  222. pass
  223. def write_foot (self):
  224. """Write any necessary footer material at recipe end."""
  225. pass
  226. @pluggable_method
  227. def write_inghead(self):
  228. """Write any necessary markup before ingredients."""
  229. self.out.write("\n---\n%s\n---\n"%_("Ingredients"))
  230. @pluggable_method
  231. def write_ingfoot(self):
  232. """Write any necessary markup after ingredients."""
  233. pass
  234. @pluggable_method
  235. def write_attr_head (self):
  236. """Write any necessary markup before attributes."""
  237. pass
  238. @pluggable_method
  239. def write_attr_foot (self):
  240. """Write any necessary markup after attributes."""
  241. pass
  242. @pluggable_method
  243. def write_attr (self, label, text):
  244. """Write an attribute with label and text.
  245. If we've been initialized with convert_attnames=True, the
  246. label will already be translated to our current
  247. locale. Otherwise, the label will be the same as it used
  248. internally in our database.
  249. So if your method needs to do something special based on the
  250. attribute name, we need to set convert_attnames to False (and
  251. do any necessary i18n of the label name ourselves.
  252. """
  253. self.out.write("%s: %s\n"%(label, text.strip()))
  254. @pluggable_method
  255. def write_text (self, label, text):
  256. """Write a text chunk.
  257. This could include markup if we've been initialized with
  258. do_markup=False. Otherwise, markup will be handled by the
  259. handle_markup methods (handle_italic, handle_bold,
  260. handle_underline).
  261. """
  262. self.out.write("\n---\n%s\n---\n"%label)
  263. ll=text.split("\n")
  264. for l in ll:
  265. for wrapped_line in textwrap.wrap(l):
  266. self.out.write("\n%s"%wrapped_line)
  267. self.out.write('\n\n')
  268. def handle_markup (self, txt):
  269. """Handle markup inside of txt."""
  270. if txt == None:
  271. print 'Warning, handle_markup handed None'
  272. return ''
  273. import pango
  274. outtxt = ""
  275. try:
  276. al,txt,sep = pango.parse_markup(txt,u'\x00')
  277. except:
  278. al,txt,sep = pango.parse_markup(xml.sax.saxutils.escape(txt),u'\x00')
  279. ai = al.get_iterator()
  280. more = True
  281. while more:
  282. fd,lang,atts=ai.get_font()
  283. chunk = xml.sax.saxutils.escape(txt.__getslice__(*ai.range()))
  284. trailing_newline = ''
  285. fields=fd.get_set_fields()
  286. if fields != 0: #if there are fields
  287. # Sometimes we get trailing newlines, which is ugly
  288. # because we end up with e.g. <b>Foo\n</b>
  289. #
  290. # So we define trailing_newline as a variable
  291. if chunk and chunk[-1]=='\n':
  292. trailing_newline = '\n'; chunk = chunk[:-1]
  293. if 'style' in fields.value_nicks and fd.get_style()==pango.STYLE_ITALIC:
  294. chunk=self.handle_italic(chunk)
  295. if 'weight' in fields.value_nicks and fd.get_weight()==pango.WEIGHT_BOLD:
  296. chunk=self.handle_bold(chunk)
  297. for att in atts:
  298. if att.type==pango.ATTR_UNDERLINE and att.value==pango.UNDERLINE_SINGLE:
  299. chunk=self.handle_underline(chunk)
  300. outtxt += chunk + trailing_newline
  301. more=ai.next()
  302. return outtxt
  303. def handle_italic (self,chunk):
  304. """Make chunk italic, or the equivalent."""
  305. return "*"+chunk+"*"
  306. def handle_bold (self,chunk):
  307. """Make chunk bold, or the equivalent."""
  308. return chunk.upper()
  309. def handle_underline (self,chunk):
  310. """Make chunk underlined, or the equivalent of"""
  311. return "_" + chunk + "_"
  312. @pluggable_method
  313. def write_grouphead (self, text):
  314. """The start of group of ingredients named TEXT"""
  315. self.out.write("\n%s:\n"%text.strip())
  316. @pluggable_method
  317. def write_groupfoot (self):
  318. """Mark the end of a group of ingredients.
  319. """
  320. pass
  321. @pluggable_method
  322. def write_ingref (self, amount=1, unit=None,
  323. item=None, optional=False,
  324. refid=None):
  325. """By default, we don't handle ingredients as recipes, but
  326. someone subclassing us may wish to do so..."""
  327. self.write_ing(amount=amount,
  328. unit=unit, item=item,
  329. key=None, optional=optional)
  330. @pluggable_method
  331. def write_ing (self, amount=1, unit=None, item=None, key=None, optional=False):
  332. """Write ingredient."""
  333. if amount:
  334. self.out.write("%s"%amount)
  335. if unit:
  336. self.out.write(" %s"%unit)
  337. if item:
  338. self.out.write(" %s"%item)
  339. if optional:
  340. self.out.write(" (%s)"%_("optional"))
  341. self.out.write("\n")
  342. class exporter_mult (exporter):
  343. """A basic exporter class that can handle a multiplied recipe."""
  344. def __init__ (self, rd, r, out,
  345. conv=None,
  346. change_units=True,
  347. mult=1,
  348. imgcount=1,
  349. order=['image','attr','ings','text'],
  350. attr_order=DEFAULT_ATTR_ORDER,
  351. text_attr_order=DEFAULT_TEXT_ATTR_ORDER,
  352. do_markup=True,
  353. use_ml=False,
  354. convert_attnames=True,
  355. fractions=convert.FRACTIONS_ASCII,
  356. ):
  357. """Initiate an exporter class capable of multiplying the recipe.
  358. We allow the same arguments as the base exporter class plus
  359. the following
  360. mult = number (multiply by this number)
  361. change_units = True|False (whether to change units to keep
  362. them readable when multiplying).
  363. """
  364. self.mult = mult
  365. self.change_units = change_units
  366. exporter.__init__(self, rd, r, out, conv, imgcount, order,
  367. attr_order=attr_order,
  368. text_attr_order=text_attr_order,
  369. use_ml=use_ml, do_markup=do_markup,
  370. convert_attnames=convert_attnames,
  371. fractions=fractions,
  372. )
  373. @pluggable_method
  374. def write_attr (self, label, text):
  375. #attr = gglobals.NAME_TO_ATTR[label]
  376. self.out.write("%s: %s\n"%(label, text))
  377. def _grab_attr_ (self, obj, attr):
  378. """Grab attribute attr of obj obj.
  379. Possibly manipulate the attribute we get to hand out export
  380. something readable.
  381. """
  382. if attr=='servings' or attr=='yields' and self.mult:
  383. ret = getattr(obj,attr)
  384. if type(ret) in [int,float]:
  385. fl_ret = float(ret)
  386. else:
  387. if ret is not None:
  388. print 'WARNING: IGNORING serving value ',ret
  389. fl_ret = None
  390. if fl_ret:
  391. ret = convert.float_to_frac(fl_ret * self.mult,
  392. fractions=self.fractions)
  393. if attr=='yields' :
  394. yield_unit = self._grab_attr_(obj,'yield_unit')
  395. if yield_unit:
  396. ret = '%s %s'%(ret,yield_unit) # FIXME: i18n?
  397. return ret
  398. else:
  399. return exporter._grab_attr_(self,obj,attr)
  400. def _get_amount_and_unit_ (self, ing):
  401. if self.mult != 1 and self.change_units:
  402. return self.rd.get_amount_and_unit(ing,mult=self.mult,conv=self.conv,
  403. fractions=self.fractions)
  404. else:
  405. return self.rd.get_amount_and_unit(ing,mult=self.mult,conv=self.conv,
  406. fractions=self.fractions)
  407. @pluggable_method
  408. def write_ing (self, amount=1, unit=None, item=None, key=None, optional=False):
  409. if amount:
  410. self.out.write("%s"%amount)
  411. if unit:
  412. self.out.write(" %s"%unit)
  413. if item:
  414. self.out.write(" %s"%item)
  415. if optional:
  416. self.out.write(" (%s)"%_("optional"))
  417. self.out.write("\n")
  418. class ExporterMultirec (SuspendableThread, Pluggable):
  419. name = 'Exporter'
  420. def __init__ (self, rd, recipes, out, one_file=True,
  421. ext='txt',
  422. conv=None,
  423. imgcount=1,
  424. progress_func=None,
  425. exporter=exporter,
  426. exporter_kwargs={},
  427. padding=None):
  428. """Output all recipes in recipes into a document or multiple
  429. documents. if one_file, then everything is in one
  430. file. Otherwise, we treat 'out' as a directory and put
  431. individual recipe files within it."""
  432. self.timer=TimeAction('exporterMultirec.__init__()')
  433. self.rd = rd
  434. self.recipes = recipes
  435. self.out = out
  436. self.padding=padding
  437. self.one_file = one_file
  438. Pluggable.__init__(self,[BaseExporterMultiRecPlugin])
  439. SuspendableThread.__init__(self,self.name)
  440. if progress_func: print 'Argument progress_func is obsolete and will be ignored:',progress_func
  441. self.ext = ext
  442. self.exporter = exporter
  443. self.exporter_kwargs = exporter_kwargs
  444. self.fractions = self.exporter_kwargs.get('fractions',
  445. convert.FRACTIONS_ASCII)
  446. self.DEFAULT_ENCODING = self.exporter.DEFAULT_ENCODING
  447. self.one_file = one_file
  448. def _grab_attr_ (self, obj, attr):
  449. if attr=='category':
  450. return ', '.join(self.rd.get_cats(obj))
  451. try:
  452. ret = getattr(obj,attr)
  453. except:
  454. return None
  455. else:
  456. if attr in ['preptime','cooktime']:
  457. # this 'if' ought to be unnecessary, but is kept around
  458. # for db converting purposes -- e.g. so we can properly
  459. # export an old DB
  460. if ret and type(ret)!=str:
  461. ret = convert.seconds_to_timestring(ret,fractions=self.fractions)
  462. elif attr=='rating' and ret and type(ret)!=str:
  463. if ret/2==ret/2.0:
  464. ret = "%s/5 %s"%(ret/2,_('stars'))
  465. else:
  466. ret = "%s/5 %s"%(ret/2.0,_('stars'))
  467. if type(ret) in types.StringTypes and attr not in ['thumb','image']:
  468. try:
  469. ret = ret.encode(self.DEFAULT_ENCODING)
  470. except:
  471. print "oops:",ret,"doesn't look like unicode."
  472. raise
  473. return ret
  474. def append_referenced_recipes (self):
  475. for r in self.recipes[:]:
  476. reffed = self.rd.db.execute(
  477. 'select * from ingredients where recipe_id=? and refid is not null',r.id
  478. )
  479. for ref in reffed:
  480. rec = self.rd.get_rec(ref.refid)
  481. if not rec in self.recipes:
  482. print 'Appending recipe ',rec.title,'referenced in ',r.title
  483. self.recipes.append(rec)
  484. @pluggable_method
  485. def do_run (self):
  486. self.rcount = 0
  487. self.rlen = len(self.recipes)
  488. if not self.one_file:
  489. self.outdir=self.out
  490. if os.path.exists(self.outdir):
  491. if not os.path.isdir(self.outdir):
  492. self.outdir=self.unique_name(self.outdir)
  493. os.makedirs(self.outdir)
  494. else: os.makedirs(self.outdir)
  495. if self.one_file and type(self.out)==str:
  496. self.ofi=open(self.out,'wb')
  497. else: self.ofi = self.out
  498. self.write_header()
  499. self.suspended = False
  500. self.terminated = False
  501. first = True
  502. self.append_referenced_recipes()
  503. for r in self.recipes:
  504. self.check_for_sleep()
  505. msg = _("Exported %(number)s of %(total)s recipes")%{'number':self.rcount,'total':self.rlen}
  506. self.emit('progress',float(self.rcount)/float(self.rlen), msg)
  507. fn=None
  508. if not self.one_file:
  509. fn=self.generate_filename(r,self.ext,add_id=True)
  510. self.ofi=open(fn,'wb')
  511. if self.padding and not first:
  512. self.ofi.write(self.padding)
  513. e=self.exporter(out=self.ofi, r=r, rd=self.rd, **self.exporter_kwargs)
  514. self.connect_subthread(e)
  515. e.do_run()
  516. self.recipe_hook(r,fn,e)
  517. if not self.one_file:
  518. self.ofi.close()
  519. self.rcount += 1
  520. first = False
  521. self.write_footer()
  522. if self.one_file:
  523. self.ofi.close()
  524. self.timer.end()
  525. self.emit('progress',1,_("Export complete."))
  526. print_timer_info()
  527. @pluggable_method
  528. def write_header (self):
  529. pass
  530. @pluggable_method
  531. def write_footer (self):
  532. pass
  533. def generate_filename (self, rec, ext, add_id=False):
  534. title=rec.title
  535. # get rid of potentially confusing characters in the filename
  536. # Windows doesn't like a number of special characters, so for
  537. # the time being, we'll just get rid of all non alpha-numeric
  538. # characters
  539. ntitle = ""
  540. for c in title:
  541. if re.match("[A-Za-z0-9 ]",c):
  542. ntitle += c
  543. title = ntitle
  544. # truncate long filenames
  545. max_filename_length = 252
  546. if len(title) > (max_filename_length - 4):
  547. title = title[0:max_filename_length-4]
  548. # make sure there is a title
  549. if not title:
  550. title = _("Recipe")
  551. title=title.replace("/"," ")
  552. title=title.replace("\\"," ")
  553. # Add ID #
  554. if add_id:
  555. title = title + str(rec.id)
  556. file_w_ext="%s%s%s"%(self.unique_name(title),os.path.extsep,ext)
  557. return os.path.join(self.outdir,file_w_ext)
  558. def recipe_hook (self, rec, filename=None, exporter=None):
  559. """Intended to be subclassed by functions that want a chance
  560. to act on each recipe, possibly knowing the name of the file
  561. the rec is going to. This makes it trivial, for example, to build
  562. an index (written to a file specified in write_header."""
  563. pass
  564. def unique_name (self, filename):
  565. if os.path.exists(filename):
  566. n=1
  567. fn,ext=os.path.splitext(filename)
  568. if ext: dot=os.path.extsep
  569. else: dot=""
  570. while os.path.exists("%s%s%s%s"%(fn,n,dot,ext)):
  571. n += 1
  572. return "%s%s%s%s"%(fn,n,dot,ext)
  573. else:
  574. return filename
  575. def check_for_sleep (self):
  576. if self.terminated:
  577. raise Exception("Exporter Terminated!")
  578. while self.suspended:
  579. if self.terminated:
  580. debug('Thread Terminated!',0)
  581. raise Exception("Exporter Terminated!")
  582. if gglobals.use_threads:
  583. time.sleep(1)
  584. else:
  585. time.sleep(0.1)
  586. def terminate (self):
  587. self.terminated = True
  588. def suspend (self):
  589. self.suspended = True
  590. def resume (self):
  591. self.suspended = False