1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
47 """Thrown if parsing of data read from file fails."""
48 pass
49
50
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
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
80
83
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
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
105
106
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
119 current_line = file.readline()
120
121 self._load_header(current_line.rstrip("\r\n"))
122 current_line = file.readline()
123
124 if current_line.startswith("PREFERRED-PERIOD:"):
125 self._load_preferred_period(current_line[17:].rstrip("\r\n"))
126 current_line = file.readline()
127
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
136 while current_line.startswith("EVENT:"):
137 self._load_event(current_line[6:].rstrip("\r\n"))
138 current_line = file.readline()
139
140 if self.file_version >= (0, 3, 0):
141 self._load_footer(current_line.rstrip("\r\n"))
142 current_line = file.readline()
143
144 if current_line:
145 raise ParseException("File continues after EOF marker.")
146
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
178
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
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
257
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
277
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
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
312 for event in self.get_all_events():
313 save(event)
314
317
318
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
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
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
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
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
391
392
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
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
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
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