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):
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