Completed
Push — qml ( 077131...7ef1da )
by Olivier
01:03
created

Task._parse()   C

Complexity

Conditions 7

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 7
dl 0
loc 30
rs 5.5
c 2
b 0
f 0
1
from datetime import datetime, date, time, MAXYEAR, timedelta
2
import re
3
from enum import Enum
4
5
from PyQt5 import QtCore
6
7
from qtodotxt2.lib.task_htmlizer import TaskHtmlizer
8
9
10
class RecursiveMode(Enum):
11
    completitionDate = 0  # Original due date mode: Task recurs from original due date
12
    originalDueDate = 1  # Completion date mode: Task recurs from completion date
13
14
15
class Recursion:
16
    mode = None
17
    increment = None
18
    interval = None
19
20
    def __init__(self, arg_mode, arg_increment, arg_interval):
21
        self.mode = arg_mode
22
        self.increment = arg_increment
23
        self.interval = arg_interval
24
25
26
class TaskSorter(object):
27
    
28
    @staticmethod
29
    def projects(tasks):
30
        def tmp(task):
31
            prj = task.projects if task.projects else ["zz"]
32
            return prj, task
33
        return sorted(tasks, key=tmp)
34
35
    @staticmethod
36
    def contexts(tasks):
37
        def tmp(task):
38
            ctx = task.contexts if task.contexts else ["zz"]
39
            return ctx, task
40
        return sorted(tasks, key=tmp)
41
42
    @staticmethod
43
    def due(tasks):
44
        def tmp(task):
45
            if task.due:
46
                return task.due, task
47
            else:
48
                return datetime(MAXYEAR, 1, 1), task
49
        return sorted(tasks, key=tmp, reverse=False)
50
51
    @staticmethod
52
    def default(tasks):
53
        return sorted(tasks, reverse=False)
54
55
56
class Task(QtCore.QObject):
57
    """
58
    Represent a task as defined in todo.txt format
59
    Take a line in todo.txt format as argument
60
    Arguments are read-only, reparse string to modify them or
61
    use one the modification methods such as setCompleted()
62
    """
63
64
    modified = QtCore.pyqtSignal(object)
65
66
    def __init__(self, text):
67
        QtCore.QObject.__init__(self)
68
        self._settings = QtCore.QSettings()
69
        self._highest_priority = 'A'
70
        # all other class attributes are defined in _reset method
71
        # which is called in _parse
72
        self._parse(text)
73
74
    def addCreationCate(self):
75
        self._removeCreationDate()
76
        self._addCreationDate()
77
78
    def __str__(self):
79
        return self._text
80
81
    def __repr__(self):
82
        return "Task({})".format(self._text)
83
84
    def _removeCreationDate(self):
85
        match = re.match(r'^(\([A-Z]\)\s)?[0-9]{4}\-[0-9]{2}\-[0-9]{2}\s(.*)', self._text)
86
        if match:
87
            if match.group(1):
88
                self._text = match.group(1) + match.group(2)
89
            else:
90
                self._text = match.group(2)
91
92
    def addCreationDate(self):
93
        date_string = date.today().strftime('%Y-%m-%d')
94
        if re.match(r'^\([A-Z]\)', self._text):
95
            self._text = '%s %s %s' % (self._text[:3], date_string, self._text[4:])
96
        else:
97
            self._text = '%s %s' % (date_string, self._text)
98
99
    def _reset(self):
100
        self.contexts = []
101
        self.projects = []
102
        self._priority = ""
103
        self.is_complete = False
104
        self.completion_date = None
105
        self.creation_date = None
106
        self.is_future = False
107
        self._threshold = None
108
        self.threshold_error = ""
109
        self._text = ''
110
        self.description = ''
111
        self._due = None
112
        self._due_error = ""
113
        self.keywords = {}
114
        self.recursion = None
115
        self._hidden = False
116
117
    def _parse(self, line):
118
        """
119
        parse a task formated as string in todo.txt format
120
        """
121
        self._reset()
122
        words = line.split(' ')
123
        if words[0] == "x":
124
            self.is_complete = True
125
            words = words[1:]
126
            # parse next word as a completion date
127
            # required by todotxt but often not here
128
            self.completion_date = _parseDate(words[0])
129
            if self.completion_date:
130
                words = words[1:]
131
        elif re.search(r'^\([A-Z]\)$', words[0]):
132
            self._priority = words[0][1:-1]
133
            words = words[1:]
134
135
        if 'h:1' in line:
136
            self._hidden = True
137
138
        dato = _parseDate(words[0])
139
        if dato:
140
            self.creation_date = dato
141
            words = words[1:]
142
143
        self.description = " ".join(words)
144
        for word in words:
145
            self._parseWord(word)
146
        self._text = line
147
148
    @QtCore.pyqtProperty('QString', notify=modified)
149
    def text(self):
150
        return self._text
151
152
    @text.setter
153
    def text(self, txt):
154
        self._parse(txt)
155
        self.modified.emit(self)
156
157
    @QtCore.pyqtProperty('QString', notify=modified)
158
    def html(self):
159
        return self.toHtml()
160
161
    @QtCore.pyqtProperty(bool, notify=modified)
162
    def hidden(self):
163
        return self._hidden
164
165
    @hidden.setter
166
    def hidden(self, val):
167
        if self._hidden == val:
168
            return
169
        if val:
170
            self.text = self._text + ' h:1'
171
        else:
172
            txt = self._text.replace(' h:1', '')
173
            self.text = txt.replace('h:1', '')  # also take the case whe h_1 is at the begynning
174
175
    @QtCore.pyqtProperty('QString', notify=modified)
176
    def priority(self):
177
        return self._priority
178
    
179
    @QtCore.pyqtProperty('QString', notify=modified)
180
    def priorityHtml(self):
181
        htmlizer = TaskHtmlizer()
182
        return htmlizer._htmlizePriority(self.priority)
183
184
    def _parseWord(self, word):
185
        if len(word) > 1:
186
            if word.startswith('@'):
187
                self.contexts.append(word[1:])
188
            elif word.startswith('+'):
189
                self.projects.append(word[1:])
190
            elif ":" in word:
191
                self._parseKeyword(word)
192
193
    def _parseKeyword(self, word):
194
        key, val = word.split(":", 1)
195
        self.keywords[key] = val
196
        if word.startswith('due:'):
197
            self._due = _parseDateTime(word[4:])
198
            if not self._due:
199
                print("Error parsing due date '{}'".format(word))
200
                self._due_error = word[4:]
201
        elif word.startswith('t:'):
202
            self._parseFuture(word)
203
        elif word.startswith('rec:'):
204
            self._parseRecurrence(word)
205
206
    def _parseFuture(self, word):
207
        self._threshold = _parseDateTime(word[2:])
208
        if not self._threshold:
209
            print("Error parsing threshold '{}'".format(word))
210
            self.threshold_error = word[2:]
211
        else:
212
            if self._threshold > datetime.today():
213
                self.is_future = True
214
215
    def _parseRecurrence(self, word):
216
        # Original due date mode
217
        if word[4] == '+':
218
            # Test if chracters have the right format
219
            if re.match('^[1-9][bdwmy]', word[5:7]):
220
                self.recursion = Recursion(RecursiveMode.originalDueDate, word[5], word[6])
221
            else:
222
                print("Error parsing recurrence '{}'".format(word))
223
        # Completion mode
224
        else:
225
            # Test if chracters have the right format
226
            if re.match('^[1-9][bdwmy]', word[4:6]):
227
                self.recursion = Recursion(RecursiveMode.completitionDate, word[4], word[5])
228
            else:
229
                print("Error parsing recurrence '{}'".format(word))
230
    
231
    @property
232
    def due(self):
233
        return self._due
234
235
    @due.setter
236
    def due(self, val):
237
        if isinstance(val, datetime):
238
            val = dateString(val)
239
        self.text = self._replace_date(self._text, val, 'due')
240
241
    @property
242
    def dueString(self):
243
        return dateString(self._due)
244
245
    @property
246
    def threshold(self):
247
        return self._threshold
248
249
    @threshold.setter
250
    def threshold(self, val):
251
        if isinstance(val, datetime):
252
            val = dateString(val)
253
        self.text = self._replace_date(self._text, val, 't')
254
255
    @staticmethod
256
    def _replace_date(text, date_text, prefix):
257
        return re.sub(r'\s' + prefix + r'\:[0-9]{4}\-[0-9]{2}\-[0-9]{2}', ' {}:{}'.format(prefix, date_text), text)
258
259
260
    @property
261
    def thresholdString(self):
262
        return dateString(self._threshold)
263
264
    @QtCore.pyqtSlot()
265
    def toggleCompletion(self):
266
        if self.is_complete:
267
            self.setPending()
268
        else:
269
            self.setCompleted()
270
271
    def setCompleted(self):
272
        """
273
        Set a task as completed by inserting a x and current date at the begynning of line
274
        """
275
        if self.is_complete:
276
            return
277
        self.completion_date = date.today()
278
        date_string = self.completion_date.strftime('%Y-%m-%d')
279
        self._text = 'x %s %s' % (date_string, self._text)
280
        self.is_complete = True
281
        self.modified.emit(self)
282
283
    def setPending(self):
284
        """
285
        Unset completed flag from task
286
        """
287
        if not self.is_complete:
288
            return
289
        words = self._text.split(" ")
290
        d = _parseDate(words[1])
291
        if d:
292
            self._text = " ".join(words[2:])
293
        else:
294
            self._text = " ".join(words[1:])
295
        self.is_complete = False
296
        self.completion_date = None
297
        self.modified.emit(self)
298
299
    def toHtml(self):
300
        """
301
        return a task as an html block which is a pretty display of a line in todo.txt format
302
        """
303
        htmlizer = TaskHtmlizer()
304
        return htmlizer.task2html(self)
305
306
    def _getLowestPriority(self):
307
        return self._settings.value("Preferences/lowest_priority", "D")
308
309
    @QtCore.pyqtSlot()
310
    def increasePriority(self):
311
        lowest_priority = self._getLowestPriority()
312
        if self.is_complete:
313
            return
314
        if not self._priority:
315
            self._priority = lowest_priority
316
            self._text = "({}) {}".format(self._priority, self._text)
317
        elif self._priority != self._highest_priority:
318
            self._priority = chr(ord(self._priority) - 1)
319
            self._text = "({}) {}".format(self._priority, self._text[4:])
320
        self.modified.emit(self)
321
322
    @QtCore.pyqtSlot()
323
    def decreasePriority(self):
324
        lowest_priority = self._getLowestPriority()
325
        if self.is_complete:
326
            return
327
        if self._priority >= lowest_priority:
328
            self._priority = ""
329
            self._text = self._text[4:]
330
            self._text = self._text.replace("({})".format(self._priority), "", 1)
331
        elif self._priority:
332
            oldpriority = self._priority
333
            self._priority = chr(ord(self._priority) + 1)
334
            self._text = self._text.replace("({})".format(oldpriority), "({})".format(self._priority), 1)
335
        self.modified.emit(self)
336
337
    def __eq__(self, other):
338
        return self._text == other.text
339
340
    def __lt__(self, other):
341
        prio1 = self.priority if self.priority else "z"
342
        prio2 = other.priority if other.priority else "z"
343
        return (self.is_complete, prio1, self._text) < (other.is_complete, prio2, other.text)
344
345
346
def dateString(dato):
347
    """
348
    Return a datetime as a nicely formatted string
349
    """
350
    if dato.time() == time.min:
351
        return dato.strftime('%Y-%m-%d')
352
    else:
353
        return dato.strftime('%Y-%m-%d %H:%M')
354
355
356
def _incrWorkDays(startDate, daysToIncrement):
357
    while daysToIncrement > 0:
358
        if startDate.weekday() == 4:  # Friday
359
            startDate = startDate + timedelta(days=3)
360
        elif startDate.weekday() == 5:  # Saturday
361
            startDate = startDate + timedelta(days=2)
362
        else:
363
            startDate = startDate + timedelta(days=1)
364
        daysToIncrement -= 1
365
    return startDate
366
367
368
def recurTask(task):
369
    """
370
    Create the next task from a recurring task
371
    """
372
    if task.recursion.interval == 'd':
373
        if task.recursion.mode == RecursiveMode.originalDueDate:
374
            next_due_date = task.due + timedelta(days=int(task.recursion.increment))
375
        else:
376
            next_due_date = date.today() + timedelta(days=int(task.recursion.increment))
377
    elif task.recursion.interval == 'b':
378
        if task.recursion.mode == RecursiveMode.originalDueDate:
379
            next_due_date = _incrWorkDays(task.due, int(task.recursion.increment))
380
        else:
381
            next_due_date = _incrWorkDays(date.today(), int(task.recursion.increment))
382
    elif task.recursion.interval == 'w':
383
        if task.recursion.mode == RecursiveMode.originalDueDate:
384
            next_due_date = task.due + timedelta(weeks=int(task.recursion.increment))
385
        else:
386
            next_due_date = date.today() + timedelta(weeks=int(task.recursion.increment))
387
    elif task.recursion.interval == 'm':
388
        if task.recursion.mode == RecursiveMode.originalDueDate:
389
            next_due_date = task.due + timedelta(weeks=int(task.recursion.increment) * 4)  # 4 weeks in a month
390
        else:
391
            next_due_date = date.today() + timedelta(weeks=int(task.recursion.increment) * 4)  # 4 weeks in a month
392
    elif task.recursion.interval == 'y':
393
        if task.recursion.mode == RecursiveMode.originalDueDate:
394
            next_due_date = task.due + timedelta(weeks=int(task.recursion.increment) * 52)  # 52 weeks in a year
395
        else:
396
            next_due_date = date.today() + timedelta(weeks=int(task.recursion.increment) * 52)  # 52 weeks in a year
397
    else:
398
        # Test already made during line parsing - shouldn't be a problem here
399
        pass
400
    # Set new due date in old task text
401
    text = re.sub(r'\sdue\:[0-9]{4}\-[0-9]{2}\-[0-9]{2}', ' due:' + dateString(next_due_date)[0:10], task.text)
402
    return Task(text)
403
404
405
def _parseDate(string):
406
    try:
407
        return datetime.strptime(string, '%Y-%m-%d').date()
408
    except ValueError:
409
        return None
410
411
412
def _parseDateTime(string):
413
    try:
414
        return datetime.strptime(string, '%Y-%m-%d')
415
    except ValueError:
416
        try:
417
            return datetime.strptime(string, '%Y-%m-%dT%H:%M')
418
        except ValueError:
419
            return None
420
421
422