Package Gnumed :: Package timelinelib :: Package db :: Package backends :: Module file |
[frames] | no frames]

# Source Code for Module Gnumed.timelinelib.db.backends.file

      1  # Copyright (C) 2009, 2010, 2011  Rickard Lindberg, Roger Lindberg
      2  #
      3  # This file is part of Timeline.
      4  #
      5  # Timeline is free software: you can redistribute it and/or modify
      6  # it under the terms of the GNU General Public License as published by
      7  # the Free Software Foundation, either version 3 of the License, or
      8  # (at your option) any later version.
      9  #
     10  # Timeline is distributed in the hope that it will be useful,
     11  # but WITHOUT ANY WARRANTY; without even the implied warranty of
     12  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     13  # GNU General Public License for more details.
     14  #
     15  # You should have received a copy of the GNU General Public License
     16  # along with Timeline.  If not, see <http://www.gnu.org/licenses/>.
     17
     18
     19  # This database was only used in version 0.1.0 - 0.9.0.
     20  # We plan to remove this in version 1.0.0.
     21
     22
     23  from datetime import datetime
     24  from os.path import abspath
     25  import base64
     26  import codecs
     27  import os.path
     28  import re
     29  import StringIO
     30
     31  import wx
     32
     33  from timelinelib.db.backends.memory import MemoryDB
     34  from timelinelib.db.exceptions import TimelineIOError
     35  from timelinelib.db.objects import Category
     36  from timelinelib.db.objects import Event
     37  from timelinelib.db.objects import TimePeriod
     38  from timelinelib.db.utils import safe_write
     39  from timelinelib.meta.version import get_version
     40  from timelinelib.utils import ex_msg
     41
     42
     43  ENCODING = "utf-8"
     44
     45
     46 -class ParseException(Exception):
     47      """Thrown if parsing of data read from file fails."""
     48      pass
     49
     50
     51 -class FileTimeline(MemoryDB):
     52      """
     53      The general format of the file looks like this for version >= 0.3.0:
     54
     55        # Written by Timeline 0.3.0 on 2009-7-23 9:40:33
     56        PREFERRED-PERIOD:...
     57        CATEGORY:...
     58        ...
     59        EVENT:...
     60        ...
     61        # END
     62
     63      Only the first and last line are required. See comments in _load_*
     64      functions for information how the format looks like for the different
     65      parts.
     66      """
     67
     68 -    def __init__(self, path):
     69          """
     70          Create a new timeline and read data from file.
     71
     72          If the file does not exist a new timeline will be created.
     73          """
     74          MemoryDB.__init__(self)
     75          self.path = path
     76          self._load()
     77
     78 -    def _parse_time(self, time_string):
     79          return self.get_time_type().parse_time(time_string)
     80
     81 -    def _time_string(self, time):
     82          return self.get_time_type().time_string(time)
     83
     84 -    def _load(self):
     85          """
     86          Load timeline data from the file that this timeline points to.
     87
     88          This should only be done once when this class is created.
     89
     90          The data is stored internally until we do a save.
     91
     92          If a read error occurs a TimelineIOError will be raised.
     93          """
     94          if not os.path.exists(self.path):
     95              # Nothing to load. Will create a new timeline on save.
     96              return
     97          try:
     98              file = codecs.open(self.path, "r", ENCODING)
     99              try:
    100                  self.disable_save()
    101                  try:
    102                      self._load_from_lines(file)
    103                  except Exception, pe:
    104                      # This should always be a ParseException, but if we made a
    105                      # mistake somewhere we still would like to mark the file as
    106                      # corrupt so we don't overwrite it later.
    107                      msg1 = _("Unable to read timeline data from '%s'.")
    108                      msg2 = "\n\n" + ex_msg(pe)
    109                      raise TimelineIOError((msg1 % abspath(self.path)) + msg2)
    110              finally:
    111                  self.enable_save(call_save=False)
    112                  file.close()
    113          except IOError, e:
    114              msg = _("Unable to read from file '%s'.")
    115              whole_msg = (msg + "\n\n%s") % (abspath(self.path), e)
    116              raise TimelineIOError(whole_msg)
    117
    118 -    def _load_from_lines(self, file):
    119          current_line = file.readline()
    120          # Load header
    121          self._load_header(current_line.rstrip("\r\n"))
    122          current_line = file.readline()
    123          # Load preferred period
    124          if current_line.startswith("PREFERRED-PERIOD:"):
    125              self._load_preferred_period(current_line[17:].rstrip("\r\n"))
    126              current_line = file.readline()
    127          # Load categories
    128          hidden_categories = []
    129          while current_line.startswith("CATEGORY:"):
    130              (cat, hidden) = self._load_category(current_line[9:].rstrip("\r\n"))
    131              if hidden == True:
    132                  hidden_categories.append(cat)
    133              current_line = file.readline()
    134          self._set_hidden_categories(hidden_categories)
    135          # Load events
    136          while current_line.startswith("EVENT:"):
    137              self._load_event(current_line[6:].rstrip("\r\n"))
    138              current_line = file.readline()
    139          # Check for footer if version >= 0.3.0 (version read by _load_header)
    140          if self.file_version >= (0, 3, 0):
    141              self._load_footer(current_line.rstrip("\r\n"))
    142              current_line = file.readline()
    143              # Ensure no more data
    144              if current_line:
    145                  raise ParseException("File continues after EOF marker.")
    146
    147 -    def _load_header(self, header_text):
    148          """
    149          Expected format '# Written by Timeline <version> on <date>'.
    150
    151          Expected format of <version> '0.3.0[dev<revision>]'.
    152
    153          We are just interested in the first part of the version.
    154          """
    155          match = re.search(r"^# Written by Timeline (\d+)\.(\d+)\.(\d+)",
    156                            header_text)
    157          if match:
    158              major = int(match.group(1))
    159              minor = int(match.group(2))
    160              tiny = int(match.group(3))
    161              self.file_version = (major, minor, tiny)
    162          else:
    163              raise ParseException("Unable to load header from '%s'." % header_text)
    164
    165 -    def _load_preferred_period(self, period_text):
    166          """Expected format 'start_time;end_time'."""
    167          times = split_on_semicolon(period_text)
    168          try:
    169              if len(times) != 2:
    170                  raise ParseException("Unexpected number of components.")
    171              tp = TimePeriod(self.get_time_type(), self._parse_time(times[0]),
    172                              self._parse_time(times[1]))
    173              self._set_displayed_period(tp)
    174              if not tp.is_period():
    175                  raise ParseException("Length not > 0.")
    176          except ParseException, e:
    177              raise ParseException("Unable to parse preferred period from '%s': %s" % (period_text, ex_msg(e)))
    178
    179 -    def _load_category(self, category_text):
    180          """
    181          Expected format 'name;color;visible'.
    182
    183          Visible attribute added in version 0.2.0. If it is not found (we read
    184          an older file), we automatically set it to True.
    185          """
    186          category_data = split_on_semicolon(category_text)
    187          try:
    188              if len(category_data) != 2 and len(category_data) != 3:
    189                  raise ParseException("Unexpected number of components.")
    190              name = dequote(category_data[0])
    191              color = parse_color(category_data[1])
    192              visible = True
    193              if len(category_data) == 3:
    194                  visible = parse_bool(category_data[2])
    195              cat = Category(name, color, None, visible)
    196              self.save_category(cat)
    197              return (cat, not visible)
    198          except ParseException, e:
    199              raise ParseException("Unable to parse category from '%s': %s" % (category_text, ex_msg(e)))
    200
    201 -    def _load_event(self, event_text):
    202          """
    203          Expected format 'start_time;end_time;text;category[;id:data]*'.
    204
    205          Changed in version 0.4.0: made category compulsory and added support
    206          for additional data. Format for version < 0.4.0 looked like this:
    207          'start_time;end_time;text[;category]'.
    208
    209          If an event does not have a category the empty string will be written
    210          as category name. Since category names can not be the empty string
    211          there will be no confusion.
    212          """
    213          event_specification = split_on_semicolon(event_text)
    214          try:
    215              if self.file_version < (0, 4, 0):
    216                  if (len(event_specification) != 3 and
    217                      len(event_specification) != 4):
    218                      raise ParseException("Unexpected number of components.")
    219                  start_time = self._parse_time(event_specification[0])
    220                  end_time = self._parse_time(event_specification[1])
    221                  text = dequote(event_specification[2])
    222                  cat_name = None
    223                  if len(event_specification) == 4:
    224                      cat_name = dequote(event_specification[3])
    225                  category = self._get_category(cat_name)
    226                  evt = Event(self.get_time_type(), start_time, end_time, text, category)
    227                  self.save_event(evt)
    228                  return True
    229              else:
    230                  if len(event_specification) < 4:
    231                      raise ParseException("Unexpected number of components.")
    232                  start_time = self._parse_time(event_specification[0])
    233                  end_time = self._parse_time(event_specification[1])
    234                  text = dequote(event_specification[2])
    235                  category = self._get_category(dequote(event_specification[3]))
    236                  event = Event(self.get_time_type(), start_time, end_time, text, category)
    237                  for item in event_specification[4:]:
    238                      id, data = item.split(":", 1)
    239                      if id not in self.supported_event_data():
    240                          raise ParseException("Can't parse event data with id '%s'." % id)
    241                      decode = get_decode_function(id)
    242                      event.set_data(id, decode(dequote(data)))
    243                  self.save_event(event)
    244          except ParseException, e:
    245              raise ParseException("Unable to parse event from '%s': %s" % (event_text, ex_msg(e)))
    246
    251
    252 -    def _get_category(self, name):
    253          for category in self.get_categories():
    254              if category.name == name:
    255                  return category
    256          return None
    257
    258 -    def _save(self):
    259          """
    260          Save timeline data to the file that this timeline points to.
    261
    262          If we have read corrupt data from a file it is not possible to still
    263          have an instance of this database. So it is always safe to write.
    264          """
    265          def write_fn(file):
    266              self._write_header(file)
    267              self._write_preferred_period(file)
    268              self._write_categories(file)
    269              self._write_events(file)
    270              self._write_footer(file)
    271          safe_write(self.path, ENCODING, write_fn)
    272
    273 -    def _write_header(self, file):
    274          file.write("# Written by Timeline %s on %s\n" % (
    275              get_version(),
    276              self._time_string(datetime.now())))
    277
    278 -    def _write_preferred_period(self, file):
    279          tp = self._get_displayed_period()
    280          if tp is not None:
    281              file.write("PREFERRED-PERIOD:%s;%s\n" % (
    282                  self._time_string(tp.start_time),
    283                  self._time_string(tp.end_time)))
    284
    285 -    def _write_categories(self, file):
    286          def save(category):
    287              r, g, b = cat.color
    288              visible = (category not in self._get_hidden_categories())
    289              file.write("CATEGORY:%s;%s,%s,%s;%s\n" % (quote(cat.name),
    290                                                        r, g, b,
    291                                                        visible))
    292          for cat in self.get_categories():
    293              save(cat)
    294
    295 -    def _write_events(self, file):
    296          def save(event):
    297              file.write("EVENT:%s;%s;%s" % (
    298                  self._time_string(event.time_period.start_time),
    299                  self._time_string(event.time_period.end_time),
    300                  quote(event.text)))
    301              if event.category:
    302                  file.write(";%s" % quote(event.category.name))
    303              else:
    304                  file.write(";")
    305              for data_id in self.supported_event_data():
    306                  data = event.get_data(data_id)
    307                  if data != None:
    308                      encode = get_encode_function(data_id)
    309                      file.write(";%s:%s" % (data_id,
    310                                             quote(encode(data))))
    311              file.write("\n")
    312          for event in self.get_all_events():
    313              save(event)
    314
    317
    318
    319 -def parse_bool(bool_string):
    320      """
    321      Return True or False.
    322
    323      Expected format 'True' or 'False'.
    324      """
    325      if bool_string == "True":
    326          return True
    327      elif bool_string == "False":
    328          return False
    329      else:
    330          raise ParseException("Unknown boolean '%s'" % bool_string)
    331
    332
    333 -def parse_color(color_string):
    334      """
    335      Return a tuple (r, g, b) or raise exception.
    336
    337      Expected format 'r,g,b'.
    338      """
    339      def verify_255_number(num):
    340          if num < 0 or num > 255:
    341              raise ParseException("Color number not in range [0, 255], color string = '%s'" % color_string)
    342      match = re.search(r"^(\d+),(\d+),(\d+)$", color_string)
    343      if match:
    344          r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3))
    345          verify_255_number(r)
    346          verify_255_number(g)
    347          verify_255_number(b)
    348          return (r, g, b)
    349      else:
    350          raise ParseException("Color not on correct format, color string = '%s'" % color_string)
    351
    352
    353 -def split_on_semicolon(text):
    354      """
    355      The delimiter is ; but only if not proceeded by backslash.
    356
    357      Examples:
    358
    359          'foo;bar' -> ['foo', 'bar']
    360          'foo\;bar;barfoo -> ['foo\;bar', 'barfoo']
    361      """
    362      return re.split(r"(?<!\\);", text)
    363
    364
    365 -def dequote(text):
    366      def repl(match):
    367          after_backslash = match.group(1)
    368          if after_backslash == "n":
    369              return "\n"
    370          elif after_backslash == "r":
    371              return "\r"
    372          else:
    373              return after_backslash
    374      return re.sub(r"\\(.)", repl, text)
    375
    376
    377 -def quote(text):
    378      def repl(match):
    379          match_char = match.group(0)
    380          if match_char == "\n":
    381              return "\\n"
    382          elif match_char == "\r":
    383              return "\\r"
    384          else:
    385              return "\\" + match_char
    386      return re.sub(";|\n|\r|\\\\", repl, text)
    387
    388
    389 -def identity(obj):
    390      return obj
    391
    392
    393 -def encode_icon(data):
    394      """Data is wx.Bitmap."""
    395      output = StringIO.StringIO()
    396      image = wx.ImageFromBitmap(data)
    397      image.SaveStream(output, wx.BITMAP_TYPE_PNG)
    398      return base64.b64encode(output.getvalue())
    399
    400
    401 -def decode_icon(string):
    402      """Return is wx.Bitmap."""
    403      input = StringIO.StringIO(base64.b64decode(string))
    404      image = wx.ImageFromStream(input, wx.BITMAP_TYPE_PNG)
    405      return image.ConvertToBitmap()
    406
    407
    408 -def get_encode_function(id):
    409      if id == "description":
    410          return identity
    411      elif id == "icon":
    412          return encode_icon
    413      else:
    414          raise ValueError("Can't find encode function for event data with id '%s'." % id)
    415
    416
    417 -def get_decode_function(id):
    418      if id == "description":
    419          return identity
    420      elif id == "icon":
    421          return decode_icon
    422      else:
    423          raise ValueError("Can't find decode function for event data with id '%s'." % id)
    424