# -*- coding: utf-8 -*- vim:sw=4:sts=4:et:ai:si:sta:fenc=utf-8 __all__ = ["TODO", "TODOFile", "TiddlyTODOFile", "TODOs"] try: True, False except NameError: True, False = 1, 0 import os, re, types from os import path from base import scriptdir from dates import * from files import TextFile from config import ShConfigFile from TiddlyWiki import * _marker = [] def is_seq(value): return type(value) is types.ListType or type(value) is types.TupleType def quote_nl(text): if text is not None: text = text.replace("\\", r"\\") text = text.replace("\n", r"\n") return text def unquote_nl(text): if text is not None: text = text.replace(r"\n", "\n") text = text.replace(r"\\", "\\") return text class TODO: def __init__(self, title=None, desc=None, contexts=None, projects=None, tags=None, todonum=None, todoline=None): self.todonum = todonum self.title = title self.desc = desc if title is not None: self.text = quote_nl(title + (desc and "\n" + desc or "")) self.contexts = contexts or () self.projects = projects or () self.tags = tags or {} if todoline is not None: self.parse(todoline) RE_CONTEXT = re.compile(r'@([^ ]+)\s+') RE_PROJECT = re.compile(r'\+([^ ]+)\s+') RE_TAG = re.compile(r'([^= ]+)=([^ ]+)\s+') def parse(self, line): contexts = [] projects = [] tags = {} title = '' desc = None while True: mo = self.RE_CONTEXT.match(line) if mo is not None: contexts.append(mo.group(1)) line = line[mo.end(0):] continue mo = self.RE_PROJECT.match(line) if mo is not None: projects.append(mo.group(1)) line = line[mo.end(0):] continue mo = self.RE_TAG.match(line) if mo is not None: tags[mo.group(1)] = mo.group(2) line = line[mo.end(0):] continue break text = line pos = text.find("\\n") if pos == -1: title = text desc = None else: title = text[:pos] desc = text[pos+2:] self.text = text self.title = unquote_nl(title) self.desc = unquote_nl(desc) self.contexts = contexts self.projects = projects self.tags = tags def to_line(self): if self.title is None: return None tags = [] if self.contexts is not None: tags.extend(map(lambda c: "@" + c, self.contexts)) if self.projects is not None: tags.extend(map(lambda p: "+" + p, self.projects)) if self.tags is not None: tags.extend(map(lambda (k, v): "%s=%s" % (k, v), self.tags.items())) line = "" if tags: line = line + " ".join(tags) + " " line = line + quote_nl(self.title) if self.desc is not None: line = line + "\\n" + quote_nl(self.desc) return line def to_string(self): id = self.tags.get("id", None) if self.is_done(): string = "TODO[%s], fait le %s:" % (id, self.tags["done"]) else: string = "TODO[%s]:" % id tags = [] if self.contexts is not None: tags.extend(map(lambda c: "@" + c, self.contexts)) if self.projects is not None: tags.extend(map(lambda p: "+" + p, self.projects)) for tag in self.tags: if tag != "id" and tag != "done" and not tag.endswith("_purged"): tags.append("%s=%s" % (tag, self.tags[tag])) if tags: string = string + " " + " ".join(tags) string = string + "\n" string = string + self.title if self.desc: string = string + "\n\n" + self.desc return string def __repr__(self): return "TODO(%s)" % self.title # Accès aux tags def __len__(self): return len(self.tags) def __getitem__(self, key, default=_marker): if default is _marker: return self.tags[key] else: return self.tags.get(key, default) get = __getitem__ def __setitem__(self, key, value): self.tags[key] = value def __delitem__(self, key): del self.tags[key] def __contains__(self, key): return key in self.tags def has_key(self, key): return self.tags.has_key(key) def keys(self): return self.tags.keys() def values(self): return self.tags.values() def items(self): return self.tags.items() def is_done(self): return self.has_key("done") def is_project_purged(self, project): return self.has_key("%s_purged" % project) def set_project_purged(self, project, date=None): if date is None: date = datef() self.tags["%s_purged" % project] = date def has_projects(self): return len(self.projects) != 0 def is_all_purged(self): for project in self.projects: if not self.is_project_purged(project): return False return True def is_project_released(self, project): return self.has_key("%s_released" % project) def set_project_released(self, project, date=None): if date is None: date = datef() self.tags["%s_released" % project] = date # Filtre def filter(self, filters=None, **kw): if filters is None: filters = kw else: filters.update(kw) for filter, value in filters.items(): if filter == "project": if is_seq(value): projects = value for project in projects: if project not in self.projects: return False else: project = value if project not in self.projects: return False elif filter == "context": if is_seq(value): contexts = value for context in contexts: if context not in self.contexts: return False else: context = value if context not in self.contexts: return False elif filter == "todonum": if self.todonum != value: return False else: if value is None: if filter in self.tags: return False elif value == '*': if filter not in self.tags: return False else: if filter not in self.tags or self.tags[filter] != value: return False return True class TODOFile(TextFile): def __init__(self, file, DONE_file=False, raise_exception=False): TextFile.__init__(self) TODO_txt = DONE_file and 'DONE.txt' or 'TODO.txt' todo_txt = DONE_file and 'done.txt' or 'todo.txt' if path.isdir(file): # on a donné le nom d'un répertoire. y chercher un fichier nommé # TODO.txt ou todo.txt dir = file todofile = path.join(dir, TODO_txt) if not path.exists(todofile): todofile2 = path.join(dir, todo_txt) if path.exists(todofile2): todofile = todofile2 else: # on a donné un nom de fichier todofile = file self.load(todofile, raise_exception=raise_exception) def __update_todonum(self): todonum = 1 for todo in self.todos: todo.todonum = todonum todonum = todonum + 1 def readlines(self, raise_exception=True): TextFile.readlines(self, raise_exception=raise_exception) self.todos = [] for line in self.lines: self.todos.append(TODO(todoline=line)) self.__update_todonum() def writelines(self): """Ecrire les TODOS et les DONEs dans le fichier """ lines = [] for todo in self.todos: lines.append(todo.to_line()) self.lines = lines TextFile.writelines(self) save = writelines def __len__(self): return len(self.todos) def __getitem__(self, index): return self.todos[index] def list(self, filters=None, **kw): if type(filters) is types.IntType: return [self.todos[filters]] elif isinstance(filters, TODO): todo = filters if todo in self.todos: return [todo] else: id = todo.get("id", None) if id is None: return [] filters = {"id": id} if filters is None: filters = kw else: filters.update(kw) return filter(lambda t: t.filter(filters), self.todos) def get(self, filters=None, **kw): todos = self.list(filters, **kw) if len(todos) == 0: return None elif len(todos) > 1: raise ValueError("Plusieurs tâches correspondent aux critères") else: return todos[0] def remove(self, filters=None, **kw): todos = self.list(filters, **kw) for todo in todos: self.todos.remove(todo) self.__update_todonum() def put(self, todo): if not isinstance(todo, TODO): todo = TODO(todoline=todo) self.remove(todo) self.todos.append(todo) self.__update_todonum() return todo def get_file(self): return self.pf def can_release(self): return False def release(self, released, release_date=None, version=None, changes=None): pass class TiddlyTODOFile: RE_DATE = re.compile(r'(\d\d)/(\d\d)/(\d\d\d\d)$') RE_ID = re.compile(r'(\d\d)/(\d\d)/(\d\d\d\d)-(\d+)$') def __init__(self, file, project): if path.isdir(file): file = path.join(file, "TODO.html") self.tw = TiddlyWiki(file) self.project = project def get_tiddler(self, id): mo = self.RE_ID.match(id) if mo is not None: tiddler = u"TODO-%s%s%s-%s" % (mo.group(3), mo.group(2), mo.group(1), mo.group(4)) else: tiddler = ensure_unicode(id) return tiddler def get(self, id): return tw[self.get_tiddler(id)] def remove(self, todo_or_id): if isinstance(todo_or_id, TODO): todo = todo_or_id id = todo.get("id", None) else: id = todo_or_id if id is None: raise ValueError("Il n'est possible de mettre à jour qu'un TODO avec un id") self.tw.remove(self.get_tiddler(id)) def put(self, todo): id = todo.get("id", None) if id is None: raise ValueError("Il n'est possible de mettre à jour qu'un TODO avec un id") tiddler = self.get_tiddler(id) modifier = None modified = None twtext = todo.text twtags = map(lambda c: "@" + c, todo.contexts) + map(lambda p: "+" + p, todo.projects) if "date" in todo.tags: mo = self.RE_DATE.match(todo.tags["date"]) if mo is not None: twtags.append("TODO-%s%s%s" % (mo.group(3), mo.group(2), mo.group(1))) else: twtags.append("TODO-%s" % todo.tags["date"]) if todo.is_done(): donetag = todo.is_project_released(self.project) and "RELEASED" or "DONE" twtags.append(donetag) twtext = twtext + "\n\nFAIT le %s" % todo.tags["done"] else: twtags.append("TODO") self.tw.put(Tiddler(tiddler, twtext, " ".join(twtags), modifier, modified)) def save(self): self.tw.save() def get_file(self): return self.tw.twpf def can_release(self): return True def release(self, released, release_date=None, version=None, changes=None): release_name = datef("Release-%Y%m%d", release_date) tiddler = release_name count = 0 for tiddler in self.tw: t = self.tw[tiddler] if t.tags_contains(release_name): count = count + 1 if count == 0: tiddler = release_name else: tiddler = "%s-%i" % (release_name, count) twtext = u"release du %s\nversion: %s\n" % (datef(FR_DATEF, release_date), version) if changes: twtext = twtext + "\n" + ensure_unicode(changes) if released: twtext = twtext + "\n" for done in released: twtext = twtext + "* <>\n" % self.get_tiddler(done.get("id", None)) twtags = [release_name, "release"] self.tw.put(Tiddler(tiddler, twtext, " ".join(twtags))) class TODOs: DEFAULTS = {"scriptdir": scriptdir} def __init__(self, todorcfile=None): if todorcfile is None: todorcfile = path.join(os.environ["HOME"], ".utools/todorc") if path.exists(todorcfile): todorc = ShConfigFile(todorcfile, defaults=self.DEFAULTS) else: todorc = self.DEFAULTS tododir = todorc.get("tododir", path.split(todorcfile)[0]) todofile = todorc.get("todofile", path.join(tododir, "TODO.txt")) donefile = todorc.get("donefile", path.join(tododir, "DONE.txt")) projdirs = todorc.get("projdirs", "") self.tododir = tododir self.todofile = todofile self.donefile = donefile self.projdirs = filter(None, projdirs.split(":")) self.todos = TODOFile(self.todofile) self.dones = TODOFile(self.donefile) def get_projdir(self, project): for projdir in self.projdirs: dir = path.join(projdir, project) if path.isdir(dir): return dir return None def __len__(self): return len(self.todos) def __getitem__(self, index): return self.todos[index] def __str__(self): lines = [] for todo in self.todos: lines.append("%i: %s" % (todo.todonum, todo.to_string())) return "\n".join(lines) def p(self): print self ## Interface utilisateur def list(self, filters=None, **kw): return self.todos.list(filters, **kw) def get(self, filters=None, **kw): todos = self.list(filters, **kw) if len(todos) == 0: return None elif len(todos) > 1: raise ValueError("Plusieurs tâches correspondent aux critères") else: return todos[0] def __put_in_projects(self, todo, DONE_file=False): for project in todo.projects: projdir = self.get_projdir(project) if projdir is not None: twhtml = path.join(projdir, "TODO.html") if path.exists(twhtml): todofile = TiddlyTODOFile(twhtml, project) else: if DONE_file: todofile = TODOFile(projdir, DONE_file=False) todofile.remove(todo) todofile.save() todofile = TODOFile(projdir, DONE_file=DONE_file) todofile.put(todo) todofile.save() def put(self, todo): todo = self.todos.put(todo) self.__put_in_projects(todo) self.todos.save() def add(self, title=None, desc=None, contexts=None, projects=None, tags=None, set_done=False, todoline=None): tags = tags or {} # Calculer la date date = datef() if not tags.has_key("date"): tags["date"] = date # Faut-il finir la tâche tout de suite? if set_done and not tags.has_key("done"): tags["done"] = date # Calculer l'id if not tags.has_key("id"): count = 1 for todo in self.todos: if todo.has_key("id") and todo["id"].startswith(date + "-"): count = count + 1 elif todo.has_key("date") and todo["date"] == date: count = count + 1 for todo in self.dones: if todo.has_key("id") and todo["id"].startswith(date + "-"): count = count + 1 elif todo.has_key("date") and todo["date"] == date: count = count + 1 tags["id"] = "%s-%i" % (date, count) if todoline is None: todo = TODO(title, desc, contexts, projects, tags) else: todo = TODO(todoline=todoline) todo.tags.update(tags) self.put(todo) def do(self, filters=None, **kw): todos = self.list(filters, **kw) date = datef() modified = False for todo in todos: if not todo.is_done(): todo["done"] = date if todo.has_key("pri"): del todo["pri"] modified = True self.__put_in_projects(todo) if modified: self.todos.save() def __remove_from_projects(self, todo): for project in todo.projects: projdir = self.get_projdir(project) if projdir is not None: twhtml = path.join(projdir, "TODO.html") if path.exists(twhtml): todofile = TiddlyTODOFile(twhtml, project) else: todofile = TODOFile(projdir) todofile.remove(todo) todofile.save() def remove(self, filters=None, **kw): todos = self.list(filters, **kw) modified = False for todo in todos: self.todos.remove(todo) modified = True self.__remove_from_projects(todo) if modified: self.todos.save() def purge(self, project=None): modified = False if project is None: # purger les tâches qui ne sont assignées à aucun projet todos = self.todos.list(done='*') dones = [] for todo in todos: if not todo.has_projects(): dones.append(todo) for done in dones: self.dones.put(done) self.todos.remove(done) modified = True self.__put_in_projects(done, DONE_file=True) else: # D'abord calculer les tâches qui sont elligibles à la purge todos = self.todos.list(project=project, done='*') # Puis les marquer comme purgées dones = [] purgeable = [] for todo in todos: if not todo.is_project_purged(project): todo.set_project_purged(project) modified = True if todo.is_all_purged(): purgeable.append(todo) else: dones.append(todo) for done in dones: self.__put_in_projects(done) for done in purgeable: self.dones.put(done) self.todos.remove(done) modified = True self.__put_in_projects(done, DONE_file=True) if modified: self.todos.save() self.dones.save() def list_releaseable(self, project): """Lister les tâches qui sont concernées par self.release() """ releaseable = [] # chercher dans self.todos purged = self.todos.list(project=project, done='*') purged = filter(lambda t: t.is_project_purged(project), purged) for todo in purged: if not todo.is_project_released(project): releaseable.append(todo) # puis dans self.dones purged = self.dones.list(project=project, done='*') purged = filter(lambda t: t.is_project_purged(project), purged) for todo in purged: if not todo.is_project_released(project): releaseable.append(todo) return releaseable def mark_released(self, project): """Marquer les tâches du projet comme released Retourner le TODOFile ou TiddlyTODOFile qui correspond au projet """ #released = [] # D'abord calculer les tâches qui sont elligibles à la release, puis les marquer comme released # d'abord pour self.todos purged = self.todos.list(project=project, done='*') purged = filter(lambda t: t.is_project_purged(project), purged) modified = False for todo in purged: if not todo.is_project_released(project): todo.set_project_released(project) modified = True #released.append(todo) # Mettre à jour les projets self.__put_in_projects(todo) if modified: self.todos.save() # puis pour self.dones purged = self.dones.list(project=project, done='*') purged = filter(lambda t: t.is_project_purged(project), purged) modified = False for todo in purged: if not todo.is_project_released(project): todo.set_project_released(project) modified = True #released.append(todo) # Mettre à jour les projets self.__put_in_projects(todo, DONE_file=True) if modified: self.dones.save() def get_todofile(self, project): """Obtenir le TODOFile ou le TiddlyTODOFile correspondant au projet, ou None si le projet est invalide """ projdir = self.get_projdir(project) if projdir is not None: twhtml = path.join(projdir, "TODO.html") if path.exists(twhtml): todofile = TiddlyTODOFile(twhtml, project) else: todofile = TODOFile(projdir) return todofile return None class Report: # True si une tâche avec priorité a été rencontrée pri = False # Nom name = None # True si une tâche valide a été rencontrée valid = False # statistiques pour les tâches valides valid_open = 0 valid_closed = 0 valid_total = 0 valid_open_perc = 0 valid_closed_perc = 0 # statistiques pour les tâches purgées purged_open = 0 purged_closed = 0 purged_total = 0 purged_open_perc = 0 purged_closed_perc = 0 # statistiques pour toutes les tâches open = 0 closed = 0 total = 0 open_perc = 0 closed_perc = 0 def __init__(self, name): self.name = name def update_perc(self): if self.valid_total > 0: self.valid_open_perc = self.valid_open * 100 / self.valid_total self.valid_closed_perc = self.valid_closed * 100 / self.valid_total if self.purged_total > 0: self.purged_open_perc = self.purged_open * 100 / self.purged_total self.purged_closed_perc = self.purged_closed * 100 / self.purged_total if self.total > 0: self.open_perc = self.open * 100 / self.total self.closed_perc = self.closed * 100 / self.total def update_open(self, valid=True): if valid: self.valid_open = self.valid_open + 1 self.valid_total = self.valid_total + 1 else: self.purged_open = self.purged_open + 1 self.purged_total = self.purged_total + 1 self.open = self.open + 1 self.total = self.total + 1 self.update_perc() def update_closed(self, valid=True): if valid: self.valid_closed = self.valid_closed + 1 self.valid_total = self.valid_total + 1 else: self.purged_closed = self.purged_closed + 1 self.purged_total = self.purged_total + 1 self.closed = self.closed + 1 self.total = self.total + 1 self.update_perc() def update(self, todo, valid=False): if todo.is_done(): self.update_closed(valid) else: self.update_open(valid) self.valid = self.valid or valid def valid_closed_bar(self, max=20): n = self.valid_closed_perc * max / 100 return "=" * n + (max - n) * " " def purged_closed_bar(self, max=20): n = self.purged_closed_perc * max / 100 return "=" * n + (max - n) * " " def closed_bar(self, max=20): n = self.closed_perc * max / 100 return "=" * n + (max - n) * " " def print_reports(self, title, reports): if not reports: return print title print "-" * len(title) print def cmp_report(r0, r1): r = cmp(r0.pri, r1.pri) if r != 0: return r r = -cmp(r0.valid_closed_perc, r1.valid_closed_perc) if r != 0: return r return cmp(r0.name, r1.name) reports.sort(cmp_report) maxnamelen = reduce(max, map(lambda r: len(r.name), reports)) for report in reports: print "%s %3i%% [%s] %*s %2i / %i tâche(s)" % ( report.pri and '*' or ' ', report.valid_closed_perc, report.valid_closed_bar(), maxnamelen, report.name, report.valid_closed, report.valid_total ) print def do_report(self): """Afficher un rapport sur les tâches """ projects = {} contexts = {} def update_stats(todo, valid, projects=projects, contexts=contexts): for project in todo.projects: report = projects.setdefault(project, self.Report("+%s" % project)) report.update(todo, valid) for context in todo.contexts: report = contexts.setdefault(context, self.Report("@%s" % context)) report.update(todo, valid) for todo in self.todos: update_stats(todo, True) for done in self.dones: update_stats(done, False) self.print_reports("Projets avec des tâches", filter(lambda p: p.valid, projects.values())) self.print_reports("Contextes avec des tâches", filter(lambda p: p.valid, contexts.values())) purged = filter(lambda p: not p.valid, projects.values()) if purged: title = "Projets terminés (pas de tâches)" print title print "-" * len(title) print maxnamelen = reduce(max, map(lambda r: len(r.name), purged)) for report in purged: print " %*s: %2i tâche(s)" % ( maxnamelen, report.name, report.purged_total )