Completed
Push — qml ( 55213b...077131 )
by Olivier
01:01
created

Task.due()   A

Complexity

Conditions 2

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
c 0
b 0
f 0
dl 0
loc 3
rs 10
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
116
    def _parse(self, line):
117
        """
118
        parse a task formated as string in todo.txt format
119
        """
120
        self._reset()
121
        words = line.split(' ')
122
        if words[0] == "x":
123
            self.is_complete = True
124
            words = words[1:]
125
            # parse next word as a completion date
126
            # required by todotxt but often not here
127
            self.completion_date = _parseDate(words[0])
128
            if self.completion_date:
129
                words = words[1:]
130
        elif re.search(r'^\([A-Z]\)$', words[0]):
131
            self._priority = words[0][1:-1]
132
            words = words[1:]
133
134
        dato = _parseDate(words[0])
135
        if dato:
136
            self.creation_date = dato
137
            words = words[1:]
138
139
        self.description = " ".join(words)
140
        for word in words:
141
            self._parseWord(word)
142
        self._text = line
143
144
    @QtCore.pyqtProperty('QString', notify=modified)
145
    def text(self):
146
        return self._text
147
148
    @text.setter
149
    def text(self, txt):
150
        self._parse(txt)
151
        self.modified.emit(self)
152
153
    @QtCore.pyqtProperty('QString', notify=modified)
154
    def html(self):
155
        return self.toHtml()
156
157
    @QtCore.pyqtProperty('QString', notify=modified)
158
    def priority(self):
159
        return self._priority
160
    
161
    @QtCore.pyqtProperty('QString', notify=modified)
162
    def priorityHtml(self):
163
        htmlizer = TaskHtmlizer()
164
        return htmlizer._htmlizePriority(self.priority)
165
166
    def _parseWord(self, word):
167
        if len(word) > 1:
168
            if word.startswith('@'):
169
                self.contexts.append(word[1:])
170
            elif word.startswith('+'):
171
                self.projects.append(word[1:])
172
            elif ":" in word:
173
                self._parseKeyword(word)
174
175
    def _parseKeyword(self, word):
176
        key, val = word.split(":", 1)
177
        self.keywords[key] = val
178
        if word.startswith('due:'):
179
            self._due = _parseDateTime(word[4:])
180
            if not self._due:
181
                print("Error parsing due date '{}'".format(word))
182
                self._due_error = word[4:]
183
        elif word.startswith('t:'):
184
            self._parseFuture(word)
185
        elif word.startswith('rec:'):
186
            self._parseRecurrence(word)
187
188
    def _parseFuture(self, word):
189
        self._threshold = _parseDateTime(word[2:])
190
        if not self._threshold:
191
            print("Error parsing threshold '{}'".format(word))
192
            self.threshold_error = word[2:]
193
        else:
194
            if self._threshold > datetime.today():
195
                self.is_future = True
196
197
    def _parseRecurrence(self, word):
198
        # Original due date mode
199
        if word[4] == '+':
200
            # Test if chracters have the right format
201
            if re.match('^[1-9][bdwmy]', word[5:7]):
202
                self.recursion = Recursion(RecursiveMode.originalDueDate, word[5], word[6])
203
            else:
204
                print("Error parsing recurrence '{}'".format(word))
205
        # Completion mode
206
        else:
207
            # Test if chracters have the right format
208
            if re.match('^[1-9][bdwmy]', word[4:6]):
209
                self.recursion = Recursion(RecursiveMode.completitionDate, word[4], word[5])
210
            else:
211
                print("Error parsing recurrence '{}'".format(word))
212
    
213
    @property
214
    def due(self):
215
        return self._due
216
217
    @due.setter
218
    def due(self, val):
219
        if isinstance(val, datetime):
220
            val = dateString(val)
221
        self.text = self._replace_date(self._text, val, 'due')
222
223
    @property
224
    def dueString(self):
225
        return dateString(self._due)
226
227
    @property
228
    def threshold(self):
229
        return self._threshold
230
231
    @threshold.setter
232
    def threshold(self, val):
233
        if isinstance(val, datetime):
234
            val = dateString(val)
235
        self.text = self._replace_date(self._text, val, 't')
236
237
    @staticmethod
238
    def _replace_date(text, date_text, prefix):
239
        return re.sub(r'\s' + prefix + r'\:[0-9]{4}\-[0-9]{2}\-[0-9]{2}', ' {}:{}'.format(prefix, date_text), text)
240
241
242
    @property
243
    def thresholdString(self):
244
        return dateString(self._threshold)
245
246
    @QtCore.pyqtSlot()
247
    def toggleCompletion(self):
248
        if self.is_complete:
249
            self.setPending()
250
        else:
251
            self.setCompleted()
252
253
    def setCompleted(self):
254
        """
255
        Set a task as completed by inserting a x and current date at the begynning of line
256
        """
257
        if self.is_complete:
258
            return
259
        self.completion_date = date.today()
260
        date_string = self.completion_date.strftime('%Y-%m-%d')
261
        self._text = 'x %s %s' % (date_string, self._text)
262
        self.is_complete = True
263
        self.modified.emit(self)
264
265
    def setPending(self):
266
        """
267
        Unset completed flag from task
268
        """
269
        if not self.is_complete:
270
            return
271
        words = self._text.split(" ")
272
        d = _parseDate(words[1])
273
        if d:
274
            self._text = " ".join(words[2:])
275
        else:
276
            self._text = " ".join(words[1:])
277
        self.is_complete = False
278
        self.completion_date = None
279
        self.modified.emit(self)
280
281
    def toHtml(self):
282
        """
283
        return a task as an html block which is a pretty display of a line in todo.txt format
284
        """
285
        htmlizer = TaskHtmlizer()
286
        return htmlizer.task2html(self)
287
288
    def _getLowestPriority(self):
289
        return self._settings.value("Preferences/lowest_priority", "D")
290
291
    @QtCore.pyqtSlot()
292
    def increasePriority(self):
293
        lowest_priority = self._getLowestPriority()
294
        if self.is_complete:
295
            return
296
        if not self._priority:
297
            self._priority = lowest_priority
298
            self._text = "({}) {}".format(self._priority, self._text)
299
        elif self._priority != self._highest_priority:
300
            self._priority = chr(ord(self._priority) - 1)
301
            self._text = "({}) {}".format(self._priority, self._text[4:])
302
        self.modified.emit(self)
303
304
    @QtCore.pyqtSlot()
305
    def decreasePriority(self):
306
        lowest_priority = self._getLowestPriority()
307
        if self.is_complete:
308
            return
309
        if self._priority >= lowest_priority:
310
            self._priority = ""
311
            self._text = self._text[4:]
312
            self._text = self._text.replace("({})".format(self._priority), "", 1)
313
        elif self._priority:
314
            oldpriority = self._priority
315
            self._priority = chr(ord(self._priority) + 1)
316
            self._text = self._text.replace("({})".format(oldpriority), "({})".format(self._priority), 1)
317
        self.modified.emit(self)
318
319
    def __eq__(self, other):
320
        return self._text == other.text
321
322
    def __lt__(self, other):
323
        prio1 = self.priority if self.priority else "z"
324
        prio2 = other.priority if other.priority else "z"
325
        return (self.is_complete, prio1, self._text) < (other.is_complete, prio2, other.text)
326
327
328
def dateString(dato):
329
    """
330
    Return a datetime as a nicely formatted string
331
    """
332
    if dato.time() == time.min:
333
        return dato.strftime('%Y-%m-%d')
334
    else:
335
        return dato.strftime('%Y-%m-%d %H:%M')
336
337
338
def _incrWorkDays(startDate, daysToIncrement):
339
    while daysToIncrement > 0:
340
        if startDate.weekday() == 4:  # Friday
341
            startDate = startDate + timedelta(days=3)
342
        elif startDate.weekday() == 5:  # Saturday
343
            startDate = startDate + timedelta(days=2)
344
        else:
345
            startDate = startDate + timedelta(days=1)
346
        daysToIncrement -= 1
347
    return startDate
348
349
350
def recurTask(task):
351
    """
352
    Create the next task from a recurring task
353
    """
354
    if task.recursion.interval == 'd':
355
        if task.recursion.mode == RecursiveMode.originalDueDate:
356
            next_due_date = task.due + timedelta(days=int(task.recursion.increment))
357
        else:
358
            next_due_date = date.today() + timedelta(days=int(task.recursion.increment))
359
    elif task.recursion.interval == 'b':
360
        if task.recursion.mode == RecursiveMode.originalDueDate:
361
            next_due_date = _incrWorkDays(task.due, int(task.recursion.increment))
362
        else:
363
            next_due_date = _incrWorkDays(date.today(), int(task.recursion.increment))
364
    elif task.recursion.interval == 'w':
365
        if task.recursion.mode == RecursiveMode.originalDueDate:
366
            next_due_date = task.due + timedelta(weeks=int(task.recursion.increment))
367
        else:
368
            next_due_date = date.today() + timedelta(weeks=int(task.recursion.increment))
369
    elif task.recursion.interval == 'm':
370
        if task.recursion.mode == RecursiveMode.originalDueDate:
371
            next_due_date = task.due + timedelta(weeks=int(task.recursion.increment) * 4)  # 4 weeks in a month
372
        else:
373
            next_due_date = date.today() + timedelta(weeks=int(task.recursion.increment) * 4)  # 4 weeks in a month
374
    elif task.recursion.interval == 'y':
375
        if task.recursion.mode == RecursiveMode.originalDueDate:
376
            next_due_date = task.due + timedelta(weeks=int(task.recursion.increment) * 52)  # 52 weeks in a year
377
        else:
378
            next_due_date = date.today() + timedelta(weeks=int(task.recursion.increment) * 52)  # 52 weeks in a year
379
    else:
380
        # Test already made during line parsing - shouldn't be a problem here
381
        pass
382
    # Set new due date in old task text
383
    text = re.sub(r'\sdue\:[0-9]{4}\-[0-9]{2}\-[0-9]{2}', ' due:' + dateString(next_due_date)[0:10], task.text)
384
    return Task(text)
385
386
387
def _parseDate(string):
388
    try:
389
        return datetime.strptime(string, '%Y-%m-%d').date()
390
    except ValueError:
391
        return None
392
393
394
def _parseDateTime(string):
395
    try:
396
        return datetime.strptime(string, '%Y-%m-%d')
397
    except ValueError:
398
        try:
399
            return datetime.strptime(string, '%Y-%m-%dT%H:%M')
400
        except ValueError:
401
            return None
402
403
404