├── .gitignore ├── images ├── timestamp.png ├── trellolink.png ├── trellolist.png ├── trellosync.png ├── completingtask.gif ├── documentlayout.png ├── sectionsummary.gif ├── upcomingtasks.png ├── sectionschedule.gif ├── upcomingtaskscustom.gif ├── sectionprioritization.gif ├── totalestimatedeffort.png └── weeklyefforttimeline.png ├── dependencies.json ├── ProjectPlanner.sublime-settings ├── ProjectPlannerSave.py ├── language_preparer.md ├── ProjectPlanner.sublime-commands ├── lib ├── commandline.py ├── sublime_requests.py └── trollop.py ├── Main.sublime-menu ├── example.projectplan.md ├── utils.py ├── README.md ├── models.py ├── LICENSE ├── ProjectPlannerTrello.py └── ProjectPlanner.py /.gitignore: -------------------------------------------------------------------------------- 1 | deps 2 | README.html -------------------------------------------------------------------------------- /images/timestamp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/timestamp.png -------------------------------------------------------------------------------- /images/trellolink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/trellolink.png -------------------------------------------------------------------------------- /images/trellolist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/trellolist.png -------------------------------------------------------------------------------- /images/trellosync.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/trellosync.png -------------------------------------------------------------------------------- /images/completingtask.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/completingtask.gif -------------------------------------------------------------------------------- /images/documentlayout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/documentlayout.png -------------------------------------------------------------------------------- /images/sectionsummary.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/sectionsummary.gif -------------------------------------------------------------------------------- /images/upcomingtasks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/upcomingtasks.png -------------------------------------------------------------------------------- /images/sectionschedule.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/sectionschedule.gif -------------------------------------------------------------------------------- /images/upcomingtaskscustom.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/upcomingtaskscustom.gif -------------------------------------------------------------------------------- /images/sectionprioritization.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/sectionprioritization.gif -------------------------------------------------------------------------------- /images/totalestimatedeffort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/totalestimatedeffort.png -------------------------------------------------------------------------------- /images/weeklyefforttimeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pedrokost/STProjectPlanner/HEAD/images/weeklyefforttimeline.png -------------------------------------------------------------------------------- /dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": { 3 | "*": [ 4 | "requests" 5 | ], 6 | ">=3080": [ 7 | "requests", 8 | "pygments", 9 | "python-markdown", 10 | "mdpopups", 11 | "python-jinja2", 12 | "markupsafe" 13 | ] 14 | } 15 | } -------------------------------------------------------------------------------- /ProjectPlanner.sublime-settings: -------------------------------------------------------------------------------- 1 | { 2 | "default_daily_category_workload": 8, 3 | "TRELLO_API_KEY": "", 4 | "TRELLO_API_SECRET": "", 5 | "TRELLO_TOKEN": "", 6 | "TRELLO_TEST_BOARD_ID": "", 7 | "SKIP_LISTS": [], 8 | "SKIP_CHECKLISTS": [], 9 | "DONE_LISTS": [], 10 | "show_quarters_on_graphs": false 11 | } -------------------------------------------------------------------------------- /ProjectPlannerSave.py: -------------------------------------------------------------------------------- 1 | import sublime, sublime_plugin 2 | 3 | class ProjectPlannerSave(sublime_plugin.EventListener): 4 | def on_pre_save(self, view): 5 | file_name = view.file_name() 6 | if file_name.endswith('.projectplan.md'): 7 | view.run_command('project_planner_compile') 8 | # import profile 9 | # profile.runctx("view.run_command('roadmap_compile')", {}, {'view': view}, filename="/home/pedro/roadmapcompileplugin.profile") 10 | 11 | -------------------------------------------------------------------------------- /language_preparer.md: -------------------------------------------------------------------------------- 1 | # My Magic plan 2 | 3 | ## Plan: Information 4 | 5 | Last updated: 2015-.. 6 | Last Trello up sync: 2015- 7 | Last Trello down sync: 2015-12-12 8 | 9 | ## Plan: Configuration 10 | 11 | - Daily Workload: Des 8h, And 3d 12 | 13 | ## Plan: Upcoming tasks 14 | 15 | ## Plan: Total estimated effort 16 | 17 | ## Plan: Weekly effort timeline 18 | 19 | ## Plan: Section schedule (to scale) 20 | 21 | ## Category 1 22 | [duration meta1] 23 | @schedule1 24 | - task 1 25 | - task title 2: 26 | - subtask 1 27 | - subtrask 2 28 | - task 3: 29 | 30 | ## Category 2 31 | 32 | -------------------------------------------------------------------------------- /ProjectPlanner.sublime-commands: -------------------------------------------------------------------------------- 1 | [ 2 | { "caption": "Project Planner Trello Sync: Download timings and cards", "command": "project_planner_trello" }, 3 | { "caption": "Project Planner Trello Sync: Upload card order", "command": "project_planner_trello_up" }, 4 | { "caption": "Preferences: ProjectPlanner Settings - User", 5 | "command": "open_file", 6 | "args": {"file": "${packages}/User/ProjectPlanner.sublime-settings"} 7 | }, 8 | { "caption": "Preferences: ProjectPlanner Settings - Default", 9 | "command": "open_file", 10 | "args": {"file": "${packages}/ProjectPlanner/ProjectPlanner.sublime-settings"} 11 | } 12 | ] -------------------------------------------------------------------------------- /lib/commandline.py: -------------------------------------------------------------------------------- 1 | # From SublimeGithub: https://github.com/bgreenlee/sublime-github 2 | # Copyright (c) 2011 Brad Greenlee 3 | 4 | # adapted from https://github.com/wbond/sublime_package_control/blob/master/Package%20Control.py 5 | import os.path 6 | import subprocess 7 | 8 | 9 | class BinaryNotFoundError(Exception): 10 | pass 11 | 12 | class CommandExecutionError(Exception): 13 | def __init__(self, errorcode): 14 | self.errorcode = errorcode 15 | 16 | def __str__(self): 17 | return repr('An error has occurred while executing the command') 18 | 19 | def find_binary(name): 20 | dirs = ['/usr/local/sbin', '/usr/local/bin', '/usr/sbin', '/usr/bin', 21 | '/sbin', '/bin'] 22 | for dir in dirs: 23 | path = os.path.join(dir, name) 24 | if os.path.exists(path): 25 | return path 26 | 27 | raise BinaryNotFoundError('The binary ' + name + ' could not be ' + \ 28 | 'located') 29 | 30 | 31 | def execute(args): 32 | proc = subprocess.Popen(args, stdin=subprocess.PIPE, 33 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 34 | 35 | output = proc.stdout.read() 36 | 37 | if proc.wait() == 0: 38 | return output 39 | 40 | raise CommandExecutionError(proc.returncode) 41 | -------------------------------------------------------------------------------- /Main.sublime-menu: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "caption": "Preferences", 4 | "mnemonic": "n", 5 | "id": "preferences", 6 | "children": 7 | [ 8 | { 9 | "caption": "Package Settings", 10 | "mnemonic": "P", 11 | "id": "package-settings", 12 | "children": 13 | [ 14 | { 15 | "caption": "Project Planner", 16 | "children": 17 | [ 18 | { 19 | "command": "open_file", 20 | "args": { 21 | "file": "${packages}/ProjectPlanner/ProjectPlanner.sublime-settings", 22 | }, 23 | "caption": "Settings – Default" 24 | }, 25 | { 26 | "command": "open_file", 27 | "args": {"file": "${packages}/User/ProjectPlanner.sublime-settings"}, 28 | "caption": "Settings – User" 29 | } 30 | ] 31 | } 32 | ] 33 | } 34 | ] 35 | } 36 | ] -------------------------------------------------------------------------------- /example.projectplan.md: -------------------------------------------------------------------------------- 1 | # My Plan 2 | 3 | ``` 4 | Math 3M 2d ############################## 5 | Jap 2w 2d ##### 6 | Art 2w #### 7 | None 2w #### 8 | Bio 1w 1m ## 9 | Por 2d 10 | ``` 11 | 12 | ## Plan: Information 13 | 14 | Last updated: 2016-05-04 15 | 16 | There are errors in your plan: 17 | 18 | *Past deadline*: 19 | - "[Prepare for biology midterm](https://trello.com/c/oaf76ars/23-prepare-for-biology-midterm)" (Bio) should have been completed by 2016-03-02 20 | - "Study for exam for linear algebra" (Math) should have been completed by 2016-04-01 21 | 22 | *Prerequirement mismatch*: 23 | - Bio: "Read biology book" should have been completed before - [Prepare for biology midterm](https://trello.com/c/oaf76ars/23-prepare-for-biology-midterm) [Bio 1w 2016-03-02]. Instead it will be done by 2016-05-04 24 | - Math: "Learn calculus" should have been completed before - Study for exam for calculus [Math 3d 2016-12-17]. Instead it will be done by 2017-01-12 25 | - Math: "Learn linear algebra" should have been completed before - Study for exam for linear algebra [Math 1w 2016-04-01]. Instead it will be done by 2017-01-24 26 | 27 | *Incorrect ordering of tasks with deadline*: 28 | - School stuff: Task *Study for exam for calculus* with deadline 2016-12-17 should be placed after task *Study for exam for linear algebra* with deadline 2016-04-01 29 | 30 | ## Plan: Configuration 31 | 32 | - Daily Workload: Math 8h 33 | 34 | ## Plan: Upcoming tasks 35 | 36 | - [Prepare for biology midterm](https://trello.com/c/oaf76ars/23-prepare-for-biology-midterm) [Bio 1w 2016-03-02] 37 | - Study for exam for linear algebra [Math 1w 2016-04-01] 38 | - Learn sums [Math 1w] 39 | - Read biology book [Bio 1m] 40 | - Study portuguese [Por 2d] 41 | - Pick a book to start learning Japanese [Jap 10d Math 3w] 42 | - Learn to cook [2w] 43 | - Learn about complex numbers [Math 2w Jap 2d] 44 | - Learn to draw boxes [Art 2w Math 4d 2016-12-24] 45 | - Study for exam for sums [Math 1d 2016-12-14] 46 | 47 | ### Art upcoming tasks 48 | 49 | - Learn to draw boxes [Art 2w Math 4d 2016-12-24] 50 | 51 | ### Bio upcoming tasks 52 | 53 | - [Prepare for biology midterm](https://trello.com/c/oaf76ars/23-prepare-for-biology-midterm) [Bio 1w 2016-03-02] 54 | - Read biology book [Bio 1m] 55 | 56 | ### Jap upcoming tasks 57 | 58 | - Pick a book to start learning Japanese [Jap 10d Math 3w] 59 | - Learn about complex numbers [Math 2w Jap 2d] 60 | 61 | ### Math upcoming tasks 62 | 63 | - Study for exam for linear algebra [Math 1w 2016-04-01] 64 | - Learn sums [Math 1w] 65 | - Pick a book to start learning Japanese [Jap 10d Math 3w] 66 | - Learn about complex numbers [Math 2w Jap 2d] 67 | - Study for exam for sums [Math 1d 2016-12-14] 68 | 69 | ### None upcoming tasks 70 | 71 | - Learn to cook [2w] 72 | 73 | ### Por upcoming tasks 74 | 75 | - Study portuguese [Por 2d] 76 | 77 | ### Deadlined upcoming tasks 78 | 79 | - [Prepare for biology midterm](https://trello.com/c/oaf76ars/23-prepare-for-biology-midterm) [Bio 1w 2016-03-02] 80 | - Study for exam for linear algebra [Math 1w 2016-04-01] 81 | - Learn to draw boxes [Art 2w Math 4d 2016-12-24] 82 | - Study for exam for sums [Math 1d 2016-12-14] 83 | - Study for exam for calculus [Math 3d 2016-12-17] 84 | 85 | ## Plan: Total estimated effort 86 | 87 | ``` 88 | Math 3M 2d ############################## 89 | Jap 2w 2d ##### 90 | Art 2w #### 91 | None 2w #### 92 | Bio 1w 1m ## 93 | Por 2d 94 | ``` 95 | 96 | ## Plan: 3 Weekly effort timeline 97 | 98 | ``` 99 | Art Bio Jap Math None Por 100 | 2016W18 ||| ||| ||| || 101 | 2016W19 ||||| ||||| ||||| 102 | 2016W20 |||| ||||| || 103 | ``` 104 | 105 | ## Plan: 50w Section schedule to scale 106 | 107 | ``` 108 | School stuff ▄▂▁▁▁▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▄▂▄│▄▄▄▂▁▁▁▁▁▁▁▁▁│▁▁ 109 | Language studies ▃▆▆▄▂▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁ 110 | Side projects ▃▄▄▁▃▄▂▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▄▇▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁ 111 | ``` 112 | 113 | ## School stuff (1x) 114 | [9 tasks, 2M 1d 2h 1m (Math 1M 3w, Bio 1w 1m, Por 2d)] 115 | ⌚▇▃▁▁▁▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▆▂▇│▇▇▇▂▁ 116 | 117 | - Learn sums [Math 1w] 118 | - Study for exam for sums [Math 1d 2016-12-14] 119 | - Learn calculus [Math 3w] 120 | - Study for exam for calculus [Math 3d 2016-12-17] 121 | - Learn linear algebra [Math] 122 | - Read biology book [Bio 1m] 123 | - [Prepare for biology midterm](https://trello.com/c/oaf76ars/23-prepare-for-biology-midterm) [Bio 1w 2016-03-02] 124 | - Study for exam for linear algebra [Math 1w 2016-04-01] 125 | - Learn about vertex machines [M] 126 | - Study portuguese [Por 2d] 127 | 128 | ## Language studies (7x) 129 | [1 tasks, 1M 4d (Math 3w, Jap 2w)] 130 | ⌚▃▇▆▅▃▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁▁▁▁ 131 | 132 | - Pick a book to start learning Japanese [Jap 10d Math 3w] 133 | 134 | ## Side projects (7x) 135 | [3 tasks, 1M 3w (Math 2w 4d, Art 2w, None 2w, Jap 2d)] 136 | ⌚▃▄▄▁▃▄▂▁▁│▁▁▁▁▁▁▁▁▁▁▁▁▁│▁▁▁▁▁▁▁▁▁▁▄▇▁│▁▁▁▁▁ 137 | 138 | - Make the world a better place [M Art 2w] 139 | - Learn to draw boxes [Art 2w Math 4d 2016-12-24] 140 | - Learn about complex numbers [Math 2w Jap 2d] 141 | - Learn to cook [2w] 142 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta, datetime, date 2 | from operator import attrgetter, methodcaller, itemgetter 3 | import re 4 | from collections import namedtuple 5 | 6 | def to_minutes(durstr, duration_map): 7 | value = durstr[:-1] 8 | unit = durstr[-1] 9 | return int(value) * duration_map[unit] 10 | 11 | def listdiff(a, b): 12 | b = set(b) 13 | return [aa for aa in a if aa not in b] 14 | 15 | def sparkline(values, smallest=-1, largest=-1): 16 | """ 17 | Treats null values are quarter breaks 18 | """ 19 | if len(values) == 0: 20 | return '' 21 | 22 | original_values = values 23 | ticks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇'] 24 | values = [float(val) for val in values if val is not None] 25 | smallest = min(values) if smallest == -1 else smallest 26 | largest = max(values) if largest == -1 else largest 27 | rng = largest - smallest 28 | scale = len(ticks) - 1 29 | 30 | if rng == 0: 31 | rng = largest - 0 32 | 33 | if rng != 0: 34 | return ''.join([ ticks[min(scale, round(((val - smallest) / rng) * scale))] if val is not None else '│'for val in original_values ]) 35 | else: 36 | return ''.join([ticks[0] for val in original_values]) 37 | 38 | 39 | def truncate_middle(s, n): 40 | if len(s) <= n: 41 | # string is already short-enough 42 | return s 43 | # half of the size, minus the 3 .'s 44 | n_2 = int(int(n) / 2 - 3) 45 | # whatever's left 46 | n_1 = int(n - n_2 - 3) 47 | 48 | return '{0}...{1}'.format(s[:n_1], s[-n_2:]) 49 | 50 | 51 | def weeknumber(datetime): 52 | return datetime.date().isocalendar()[1] 53 | 54 | def fmtweek(datetime): 55 | (year, week) = datetime.isocalendar()[:2] 56 | return '%04dW%02d' % (year, week) 57 | 58 | def next_available_weekday(dt): 59 | MONDAY=0 60 | SUNDAY=6 61 | 62 | if dt.weekday() == MONDAY: 63 | # Skip weekends 64 | delta = timedelta(days=3) 65 | elif dt.weekday() == SUNDAY: 66 | # Skip weekends 67 | delta = timedelta(days=2) 68 | else: 69 | delta = timedelta(days=1) 70 | return dt - delta 71 | 72 | def human_duration(total_duration, duration_categories_map, max_segments=5): 73 | groupped_duration = dict.fromkeys(duration_categories_map.keys(), 0) 74 | 75 | duration_categories = sorted(duration_categories_map.items(), key=itemgetter(1), reverse=True) 76 | duration_categories = [d[0] for d in duration_categories] 77 | 78 | for duration_cat in duration_categories: 79 | # groupped_duration[duration_cat] = int(round(total_duration / duration_categories_map[duration_cat])) 80 | groupped_duration[duration_cat] = total_duration // duration_categories_map[duration_cat] 81 | total_duration -= groupped_duration[duration_cat] * duration_categories_map[duration_cat] 82 | 83 | human_time = ' '.join(["%d%s" % (groupped_duration[cat], cat) for cat in duration_categories if groupped_duration[cat] > 0]) 84 | 85 | # Cro out low precision (this should be smarte rounding) 86 | if max_segments < len(human_time.split(' ')): 87 | human_time = ' '.join(human_time.split(' ')[:max_segments]) 88 | 89 | return human_time 90 | 91 | def mean(values): 92 | if len(values) == 0: return None 93 | return sum(values) / len(values) 94 | 95 | 96 | def has_optional_flag(string): 97 | return string is not None and "M" in string 98 | 99 | def extract_categories(string): 100 | """ 101 | 30m 102 | 5w 103 | Math 2w 104 | Math 3d Jap 9w 105 | Bio 106 | """ 107 | 108 | CATEGORY_REGEX = '(?P[a-zA-Z]{3,})?\s?(?P\d+(m|h|d|w|M|q))?' 109 | categories = {} 110 | 111 | for match in re.finditer(CATEGORY_REGEX, string): 112 | if not (match.group('duration') is None and match.group('cat') is None): 113 | 114 | cat = str(match.group('cat')) 115 | categories[cat] = {} 116 | if match.group('duration'): 117 | dur = int(match.group('duration')[:-1]) 118 | unit = match.group('duration')[-1] 119 | else: 120 | dur = None 121 | unit = None 122 | 123 | categories[cat]['duration_value'] = dur, 124 | categories[cat]['duration_unit'] = unit 125 | 126 | return categories 127 | 128 | def parse_end_date(str): 129 | DATE_FORMAT = '%Y-%m-%d' 130 | return datetime.strptime(str, DATE_FORMAT) if str else None 131 | 132 | def extract_task_metadata(task): 133 | TASK_META_MATCH_REGEX = '\[((?PM)(?![a-zA-Z])\s?)?(?P(\d+\w\s?)?(\w{3,})?(\w{3,}\s\d+\w\s?)*)(?P\d{4}-\d{2}-\d{2})?\]$' 134 | 135 | TaskMeta = namedtuple('TaskMeta', ['optional', 'categories', 'end_date']) 136 | matches = re.search(TASK_META_MATCH_REGEX, task) 137 | 138 | if matches: 139 | 140 | optional = has_optional_flag(matches.group('flags')) 141 | categories = extract_categories(matches.group('categories')) 142 | 143 | end_date = parse_end_date(matches.group('end_date')) 144 | 145 | meta = TaskMeta( 146 | optional, 147 | categories, 148 | end_date 149 | ) 150 | raw_meta = matches.group(0) 151 | else: 152 | raw_meta = "" 153 | categories = {} 154 | categories['None'] = {} 155 | categories['None']['duration_value'] = None, 156 | categories['None']['duration_unit'] = None 157 | meta = TaskMeta(False, categories, None) 158 | 159 | return (meta, raw_meta) 160 | 161 | 162 | def weighted_sampling_without_replacement(l, n, myrandom=None): 163 | """Selects without replacement n random elements from a list of (weight, item) tuples.""" 164 | 165 | if myrandom: 166 | l = sorted((myrandom.random() * x[0], x[1]) for x in l) 167 | else: 168 | import random 169 | l = sorted((random.random() * x[0], x[1]) for x in l) 170 | 171 | return l[-n:] -------------------------------------------------------------------------------- /lib/sublime_requests.py: -------------------------------------------------------------------------------- 1 | # From SublimeGithub: https://github.com/bgreenlee/sublime-github 2 | # Copyright (c) 2011 Brad Greenlee 3 | 4 | import re 5 | import requests 6 | from requests.status_codes import codes 7 | try: 8 | import http.client as httplib 9 | except ImportError: 10 | import httplib 11 | from . import commandline 12 | import sublime 13 | from io import BytesIO 14 | import logging 15 | 16 | logging.basicConfig(format='%(asctime)s %(message)s') 17 | logger = logging.getLogger() 18 | 19 | 20 | class CurlSession(object): 21 | ERR_UNKNOWN_CODE = "Curl failed with an unrecognized code" 22 | CURL_ERRORS = { 23 | 2: "Curl failed initialization.", 24 | 5: "Curl could not resolve the proxy specified.", 25 | 6: "Curl could not resolve the remote host.\n\nPlease verify that your Internet" 26 | " connection works properly." 27 | } 28 | 29 | class FakeSocket(BytesIO): 30 | def makefile(self, *args, **kw): 31 | return self 32 | 33 | def __init__(self, verify=None): 34 | self.verify = verify 35 | 36 | def _parse_http(self, text): 37 | # if the response text starts with a 302, skip to the next non-302 header 38 | text = str(text, encoding='utf-8') 39 | if re.match(r'^HTTP/.*?\s302 Found', text): 40 | m = re.search(r'(HTTP/\d+\.\d+\s(?!302 Found).*$)', text, re.S) 41 | if not m: 42 | raise Exception("Unrecognized response: %s" % text) 43 | else: 44 | text = m.group(1) 45 | 46 | # if the response text starts with a "200 Connection established" but continues with a 201, 47 | # skip the 200 header. This happens when using a proxy. 48 | # 49 | # e.g. HTTP/1.1 200 Connection established 50 | # Via: 1.1 proxy 51 | # Connection: Keep-Alive 52 | # Proxy-Connection: Keep-Alive 53 | # 54 | # HTTP/1.1 201 Created 55 | # Server: GitHub.com 56 | # ... 57 | # Status: 201 Created 58 | # ... 59 | if re.match(r'^HTTP/.*?\s200 Connection established', text): 60 | m = re.search(r'(HTTP/\d+\.\d+\s(?!200 Connection established).*$)', text, re.S) 61 | if not m: 62 | raise Exception("Unrecognized response: %s" % text) 63 | else: 64 | text = m.group(1) 65 | 66 | # remove Transfer-Encoding: chunked header, as it causes reading the response to fail 67 | # first do a quick check for it, so we can avoid doing the expensive negative-lookbehind 68 | # regex if we don't need it 69 | if "Transfer-Encoding: chunked" in text: 70 | # we do the negative-lookbehind to make sure we only strip the Transfer-Encoding 71 | # string in the header 72 | text = re.sub(r'(?M\s?)?(?P(\d+\w\s?)?(\w+)?(\w+\s\d+\w\s?)*)(?P\d{4}-\d{2}-\d{2})?\]$' 28 | meta_index = re.search(TASK_META_MATCH_REGEX, task) 29 | 30 | # Strips the initial -/+ sign 31 | if meta_index: 32 | description = task[2:meta_index.start()] 33 | else: 34 | description = task[2:] 35 | 36 | return description.strip() 37 | 38 | self._raw = raw 39 | self._meta, self._raw_meta = extract_task_metadata(raw) 40 | self._description = extract_description(raw) 41 | self._fake_duration = {} 42 | self.slots = {} 43 | self._section = section 44 | self._pos = section_order 45 | self._depends_on = None 46 | self._prerequirement_for = None 47 | 48 | @property 49 | def pos(self): 50 | return self._pos 51 | 52 | @property 53 | def section(self): 54 | return self._section 55 | 56 | @property 57 | def depends_on_deadlined(self): 58 | return self._depends_on 59 | 60 | @depends_on_deadlined.setter 61 | def depends_on_deadlined(self, task): 62 | self._depends_on = task 63 | 64 | @property 65 | def prerequirement_for_deadlined(self): 66 | return self._prerequirement_for 67 | 68 | @prerequirement_for_deadlined.setter 69 | def prerequirement_for_deadlined(self, task): 70 | self._prerequirement_for = task 71 | 72 | @property 73 | def name(self): 74 | """ 75 | Mathes task without links without meta without 76 | """ 77 | name = self._description.strip() 78 | # Remove the trello link if any 79 | if name[0] == '[' and name[-1] == ')': 80 | name = re.search('\[(?P.+)\].+', name).group('name') 81 | return name 82 | 83 | @property 84 | def is_trello_card(self): 85 | CARD_ID_REGEX = 'https\:\/\/trello\.com\/c\/(?P.+)\/' 86 | return re.search(CARD_ID_REGEX, self.raw) is not None 87 | 88 | @property 89 | def trello_url(self): 90 | CARD_ID_REGEX = 'https\:\/\/trello\.com\/c\/(?P.+)\/' 91 | return re.search(CARD_ID_REGEX, self.raw).group(0) 92 | 93 | @property 94 | def trello_id(self): 95 | CARD_ID_REGEX = 'https\:\/\/trello\.com\/c\/(?P.+)\/' 96 | match = re.search(CARD_ID_REGEX, self.raw) 97 | if not match: return None 98 | return match.group('card_id') 99 | 100 | def set_slots_for_category(self, category, slots): 101 | self.slots[category] = slots 102 | 103 | def get_slots_for_category(self, category): 104 | return self.slots[category] if category in self.slots else [] 105 | 106 | @property 107 | def is_mandatory(self): 108 | return not self.meta.optional 109 | 110 | @property 111 | def start_date(self): 112 | """ 113 | It's the date when it needs to start to be finished on time. 114 | For tasks with multiple categories, it the earliest start_time for 115 | all categories 116 | """ 117 | max_duration = max([self.category_duration(category) for category in self.categories()]) 118 | if self.meta.end_date and max_duration: 119 | return self.meta.end_date - timedelta(hours = max_duration) 120 | return None 121 | 122 | def has_category(self, category): 123 | return category in self.meta.categories.keys() 124 | 125 | def scheduled_start_date(self, category): 126 | """ 127 | Date-precesion of task start date 128 | """ 129 | 130 | slots = [] 131 | if category in ['All', 'Deadlined']: 132 | for cat in self.categories(): 133 | slots += self.get_slots_for_category(cat) 134 | else: 135 | slots = self.get_slots_for_category(category) 136 | 137 | all_dates = [s.date for s in slots] 138 | return min(all_dates) 139 | 140 | def scheduled_end_date(self, category): 141 | """ 142 | Date-precision of task end date. 143 | """ 144 | 145 | slots = [] 146 | if category in ['All', 'Deadlined']: 147 | for cat in self.categories(): 148 | slots += self.get_slots_for_category(cat) 149 | else: 150 | slots = self.get_slots_for_category(category) 151 | 152 | all_dates = [s.date for s in slots] 153 | return max(all_dates) 154 | 155 | @property 156 | def has_deadline(self): 157 | return self.meta.end_date is not None 158 | 159 | @property 160 | def description(self): 161 | return self._description 162 | 163 | @property 164 | def total_duration(self, fake_valid=True): 165 | print('TODO: to be reimplemented: task.duration') 166 | if self.meta.duration_value is not None and self.meta.duration_unit is not None: 167 | return self.meta.duration_value * Section.DURATION_MAP[self.meta.duration_unit] 168 | elif fake_valid: 169 | return sum([self._fake_duration[key] for key in self._fake_duration.keys()]) 170 | else: 171 | return None 172 | 173 | def categories(self): 174 | return list(self.meta.categories.keys()) 175 | 176 | def category_duration(self, category, fake_valid=True): 177 | if category in self.categories(): 178 | if self.meta.categories[category]['duration_value'][0] is not None and self.meta.categories[category]['duration_unit'] is not None: 179 | return self.meta.categories[category]['duration_value'][0] * Section.DURATION_MAP[self.meta.categories[category]['duration_unit']] 180 | elif fake_valid and category in self._fake_duration: 181 | return self._fake_duration[category] 182 | else: 183 | return None 184 | else: 185 | print ('TODO: what if the category is not provided') 186 | return None 187 | 188 | @total_duration.setter 189 | def total_duration(self, value): 190 | self._fake_duration = value 191 | 192 | def set_fake_duration(self, category, value): 193 | self._fake_duration[category] = value 194 | 195 | @property 196 | def raw_meta(self): 197 | return self._raw_meta 198 | 199 | @property 200 | def urgency(self): 201 | NO_DATE_DEFAULT_URGENCY = sys.maxsize - 1 202 | PAST_DATE_URGENCY_MULTIPLIER = 2 203 | if self.start_date: 204 | if self.start_date.date() < date.today(): 205 | return self.start_date.timestamp() * PAST_DATE_URGENCY_MULTIPLIER 206 | else: 207 | return self.start_date.timestamp() 208 | else: 209 | return NO_DATE_DEFAULT_URGENCY 210 | return 2 211 | 212 | def category_urgency(self, category): 213 | PAST_DATE_URGENCY_MULTIPLIER = 2 214 | 215 | def urgency_normalizer(start): 216 | if start.date() < date.today(): 217 | return start.timestamp() * PAST_DATE_URGENCY_MULTIPLIER 218 | else: 219 | return start.timestamp() 220 | 221 | if category == 'All': 222 | # Take largest urgency from all categories 223 | # Use overall 224 | cats_deadlines = [self.get_slots_for_category(cat)[0].date for cat in self.meta.categories.keys()] 225 | return urgency_normalizer(max(cats_deadlines)) 226 | elif not self.has_category(category): 227 | return 0 # just in case 228 | else: 229 | return urgency_normalizer(self.get_slots_for_category(category)[0].date) 230 | 231 | @property 232 | def raw(self): 233 | return self._raw 234 | 235 | @property 236 | def meta(self): 237 | return self._meta 238 | 239 | def __str__(self): 240 | return "- %s %s" % (self.description, self.raw_meta) 241 | 242 | def __repr__(self): 243 | return str(self) 244 | 245 | 246 | class Section(object): 247 | 'Section(lines, is_valid)' 248 | 249 | TASK_IDENTIFIER = '- ' 250 | COMPLETED_TASK_IDENTIFIER = '+ ' 251 | # TASK_META_REGEX = '\[(\w{3})?\s?(?:(\d{1,})(h|d|w|m|q))?\s?(\d{4}-\d{2}-\d{2}.*)?\]' 252 | 253 | # In minutes 254 | DURATION_MAP = { 255 | 'm': 1, 256 | 'h': 60, 257 | 'd': 480, 258 | 'w': 2400, 259 | 'M': 10080, 260 | # 'q': 504, 261 | } 262 | def __init__(self, lines, is_valid, row_at): 263 | 'Create new instance of Section(lines, is_valid)' 264 | 265 | self._lines = lines 266 | self._is_valid = is_valid 267 | self._row_at = row_at 268 | 269 | all_tasks = [] 270 | if self.is_valid: 271 | for index, raw_task in enumerate(self.raw_tasks): 272 | all_tasks.append(Task(raw_task, self, index)) 273 | 274 | self._all_tasks = all_tasks 275 | self._tasks = [task for task in all_tasks if task.is_mandatory] 276 | weight_regex = '\((?P\d+(\.\d+)?)x\)' 277 | priority_match = re.search(weight_regex, self.lines[0]) 278 | self._weight = float(priority_match.group('weight')) if priority_match else 1 279 | 280 | @property 281 | def pretty_title(self): 282 | return self.title[2:].strip() 283 | 284 | @property 285 | def title(self): 286 | title = self.lines[0] 287 | if re.search('\(.+\)', title): 288 | title = re.search('(?P.+)(\s?\(.+\))', title).group('title').strip() 289 | return title 290 | 291 | @property 292 | def weight(self): 293 | return self._weight 294 | 295 | @property 296 | def lines(self): 297 | return self._lines 298 | 299 | @property 300 | def is_valid(self): 301 | return self._is_valid 302 | 303 | @property 304 | def row_at(self): 305 | return self._row_at 306 | 307 | @property 308 | def needs_update(self): 309 | return self.lines[1].startswith('[') 310 | 311 | @property 312 | def summary(self): 313 | return "[%d tasks, %s]" % (self.num_mandatory_tasks, self.smart_duration) 314 | 315 | @property 316 | def smart_duration(self): 317 | (known_duration, untagged_count, category_durations) = self.duration 318 | sorted_cat_durs = sorted(category_durations.items(), key=itemgetter(1), reverse=True) 319 | 320 | if known_duration > 0: 321 | str = human_duration(known_duration, self.DURATION_MAP) 322 | str += ' (' + ', '.join(["%s %s" % (dur[0], human_duration(dur[1], self.DURATION_MAP, max_segments=2)) for dur in sorted_cat_durs]) + ')' 323 | if untagged_count > 0: 324 | str += " + %d tasks with missing duration" % untagged_count 325 | else: 326 | str = "Missing duration metadata" 327 | 328 | return str 329 | 330 | def find_by_line(self, line): 331 | for t in self.tasks: 332 | if t.raw == line: 333 | return t 334 | return None 335 | 336 | @property 337 | def raw_tasks(self): 338 | is_task = lambda line: line.startswith(self.TASK_IDENTIFIER) 339 | return [line for line in self.lines if is_task(line)] 340 | 341 | def completed_tasks(self): 342 | is_completed_task = lambda line: line.startswith(self.COMPLETED_TASK_IDENTIFIER) 343 | return [line for line in self.lines if is_completed_task(line)] 344 | 345 | @property 346 | def tasks(self): 347 | return self._tasks 348 | 349 | @property 350 | def all_tasks(self): 351 | return self._all_tasks 352 | 353 | @property 354 | def duration(self): 355 | total_duration = 0 # lowest units based on DURATION_MAP 356 | untagged_with_duration = 0 357 | 358 | categ_durations = {} 359 | 360 | mandatory_tasks = [task for task in self.tasks if task.is_mandatory] 361 | for task in mandatory_tasks: 362 | categories = task.categories() 363 | for category in task.categories(): 364 | total_duration += task.category_duration(category) 365 | if not category in categ_durations: 366 | categ_durations[category] = 0 367 | categ_durations[category] += task.category_duration(category) 368 | 369 | return (total_duration, untagged_with_duration, categ_durations) 370 | 371 | def __str__(self): 372 | return "<Section '%s', %d items, valid:%s>" % (self.title, self.num_mandatory_tasks, self.is_valid) 373 | 374 | def __repr__(self): 375 | return str(self) 376 | 377 | @property 378 | def num_tasks(self): 379 | # Counts lines starting with Task delimeter 380 | if not self.is_valid: return 0 381 | return len(self.tasks) 382 | 383 | @property 384 | def num_mandatory_tasks(self): 385 | # Counts lines starting with Task delimeter 386 | if not self.is_valid: return 0 387 | return len([task for task in self.tasks if task.is_mandatory]) 388 | 389 | def __lt__(self, other): 390 | return self.title < other.title 391 | 392 | class Statistics(object): 393 | """Statistics(sections)""" 394 | def __init__(self, sections): 395 | self.sections = sections 396 | self.all_tasks = self._compute_alltasks(sections) 397 | self.categories = self._compute_categories() 398 | self.category_means = self._compute_category_means() 399 | self.category_workloads = self._extract_category_overrides() 400 | 401 | def _extract_category_overrides(self): 402 | CONFIG_SECTION_TITLE = '## Plan: Configuration' 403 | CONFIG_WORKLOAD_TASK = 'Daily Workload' 404 | conf = sublime.load_settings('ProjectPlanner.sublime-settings') 405 | default_workload = conf.get('default_daily_category_workload', 8 * 60) # 8 hours in minutes 406 | 407 | workloads_dict = defaultdict(lambda: default_workload) 408 | 409 | config_section = [sec for sec in self.sections if sec.title == CONFIG_SECTION_TITLE] 410 | if len(config_section) == 0: 411 | return workloads_dict 412 | workload_task = [t for t in config_section[0].raw_tasks if CONFIG_WORKLOAD_TASK in t] 413 | if len(workload_task) == 0: 414 | return workloads_dict 415 | workload_task = workload_task[0] 416 | workloads = [c.strip() for c in workload_task.split(':')[1].split(',')] 417 | 418 | for workload in workloads: 419 | cat, dur = workload.split(' ') 420 | workloads_dict[cat] = to_minutes(dur, Section.DURATION_MAP) 421 | 422 | return workloads_dict 423 | 424 | def _compute_alltasks(self, sections): 425 | nested_tasks = map(lambda section: section.tasks, sections) 426 | return [task for tasks in nested_tasks for task in tasks] 427 | 428 | def _compute_categories(self): 429 | all_categories = [list(task.meta.categories.keys()) for task in self.all_tasks] 430 | all_categories = [cat for cats in all_categories for cat in cats] 431 | return sorted(list(set(all_categories))) 432 | 433 | def max_load_for_category(self, category): 434 | return self.category_workloads[category] 435 | 436 | def _compute_category_means(self): 437 | # Compute mean and median duration of each category's task 438 | stats = {} 439 | 440 | means = [] 441 | for category in self.categories: 442 | filtered_tasks = filter(lambda t: t.has_category(category), self.all_tasks) 443 | durations = [task.category_duration(category, fake_valid=False) for task in filtered_tasks] 444 | duros = [dur for dur in durations if dur is not None] 445 | stats[str(category)] = mean(duros) 446 | means.append(mean(duros)) 447 | 448 | # If any category has None stats, use global mean/median 449 | none_means = [index for index in range(len(means)) if means[index] is None] 450 | overall_mean = mean([mean for mean in means if mean is not None]) 451 | 452 | for none_mean in none_means: 453 | stats[str(self.categories[none_mean])] = overall_mean 454 | 455 | return stats 456 | 457 | def get_mean_duration(self, category): 458 | return self.category_means[category] 459 | 460 | -------------------------------------------------------------------------------- /lib/trollop.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | import json 3 | 4 | # import requests 5 | from . import sublime_requests as requests 6 | 7 | def get_class(str_or_class): 8 | """Accept a name or actual class object for a class in the current module. 9 | Return a class object.""" 10 | if isinstance(str_or_class, str): 11 | return globals()[str_or_class] 12 | else: 13 | return str_or_class 14 | 15 | 16 | class TrelloConnection(object): 17 | 18 | def __init__(self, api_key, oauth_token): 19 | self.session = requests.session() 20 | 21 | self.key = api_key 22 | self.token = oauth_token 23 | 24 | def request(self, method, path, params=None, body=None): 25 | if not path.startswith('/'): 26 | path = '/' + path 27 | url = 'https://api.trello.com/1' + path 28 | 29 | params = params or {} 30 | params.update({'key': self.key, 'token': self.token}) 31 | url += '?' + urlencode(params) 32 | 33 | # Trello recently got picky about headers. Only set content type if 34 | # we're submitting a payload in the body 35 | if body: 36 | headers = {'Content-Type': 'application/json'} 37 | else: 38 | headers = None 39 | response = self.session.request(method, url, data=body, headers=headers) 40 | response.raise_for_status() 41 | return response.text 42 | 43 | def get(self, path, params=None): 44 | return self.request('GET', path, params) 45 | 46 | def post(self, path, params=None, body=None): 47 | return self.request('POST', path, params, body) 48 | 49 | def put(self, path, params=None, body=None): 50 | return self.request('PUT', path, params, body) 51 | 52 | def delete(self, path, params=None, body=None): 53 | return self.request('DELETE', path, params, body) 54 | 55 | def get_board(self, board_id): 56 | return Board(self, board_id) 57 | 58 | def get_card(self, card_id): 59 | return Card(self, card_id) 60 | 61 | def set_card_position(self, card_id, new_position): 62 | path = '/cards/' + card_id + '/pos' 63 | body = json.dumps({'value': new_position}) 64 | return self.put(path, body=body) 65 | 66 | def get_list(self, list_id): 67 | return List(self, list_id) 68 | 69 | def get_checklist(self, checklist_id): 70 | return Checklist(self, checklist_id) 71 | 72 | def get_member(self, member_id): 73 | return Member(self, member_id) 74 | 75 | def get_notification(self, not_id): 76 | return Notification(self, not_id) 77 | 78 | def get_organization(self, org_id): 79 | return Organization(self, org_id) 80 | 81 | @property 82 | def me(self): 83 | """ 84 | Return a Membership object for the user whose credentials were used to 85 | connect. 86 | """ 87 | return Member(self, 'me') 88 | 89 | 90 | class Closable(object): 91 | """ 92 | Mixin for Trello objects for which you're allowed to PUT to <id>/closed. 93 | """ 94 | def close(self): 95 | path = self._prefix + self._id + '/closed' 96 | params = {'value': 'true'} 97 | result = self._conn.put(path, params=params) 98 | 99 | 100 | class Deletable(object): 101 | """ 102 | Mixin for Trello objects which are allowed to be DELETEd. 103 | """ 104 | def delete(self): 105 | path = self._prefix + self._id 106 | self._conn.delete(path) 107 | 108 | 109 | class Labeled(object): 110 | """ 111 | Mixin for Trello objects which have labels. 112 | """ 113 | 114 | # TODO: instead of set_label and get_label, just override the 'labels' 115 | # property to call set and get as appropriate. 116 | 117 | _valid_label_colors = [ 118 | 'green', 119 | 'yellow', 120 | 'orange', 121 | 'red', 122 | 'purple', 123 | 'blue', 124 | ] 125 | 126 | def set_label(self, color): 127 | color = color.lower() 128 | if color not in self._valid_label_colors: 129 | raise ValueError("invalid color") 130 | path = self._prefix + self._id + '/labels' 131 | params = {'value': color} 132 | self._conn.post(path, params=params) 133 | 134 | def clear_label(self, color): 135 | color = color.lower() 136 | if color not in self._valid_label_colors: 137 | raise ValueError("invalid color") 138 | path = self._prefix + self._id + '/labels/' + color 139 | self._conn.delete(path) 140 | 141 | 142 | class Field(object): 143 | """ 144 | A simple field on a Trello object. Maps the attribute to a key in the 145 | object's _data dict. 146 | """ 147 | 148 | def __init__(self, key=None): 149 | self.key = key 150 | 151 | def __get__(self, instance, owner): 152 | # Accessing instance._data will trigger a fetch from Trello if the 153 | # _data attribute isn't already present. 154 | return instance._data[self.key] 155 | 156 | 157 | class DateField(Field): 158 | 159 | def __get__(self, instance, owner): 160 | raw = super(DateField, self).__get__(instance, owner) 161 | return raw 162 | # return isodate.parse_datetime(raw) 163 | 164 | 165 | class ObjectField(Field): 166 | """ 167 | Maps an idSomething string attr on an object to another object type. 168 | """ 169 | 170 | def __init__(self, key, cls): 171 | 172 | self.key = key 173 | self.cls = cls 174 | 175 | def __get__(self, instance, owner): 176 | return self.related_instance(instance._conn, instance._data[self.key]) 177 | 178 | def related_instance(self, conn, obj_id): 179 | return get_class(self.cls)(conn, obj_id) 180 | 181 | 182 | class ListField(ObjectField): 183 | """ 184 | Like an ObjectField, but a list of them. For fleshing out things like 185 | idMembers. 186 | """ 187 | 188 | def __get__(self, instance, owner): 189 | ids = instance._data[self.key] 190 | conn = instance._conn 191 | return [self.related_instance(conn, id) for id in ids] 192 | 193 | 194 | class SubList(object): 195 | """ 196 | Kinda like a ListField, but for things listed under a URL subpath (like 197 | /boards/<id>/cards), as opposed to a list of ids in the document body 198 | itself. 199 | """ 200 | 201 | def __init__(self, cls): 202 | # cls may be a name of a class, or the class itself 203 | self.cls = cls 204 | 205 | # A dict of sublists, by instance id 206 | self._lists = {} 207 | 208 | def __get__(self, instance, owner): 209 | if not instance._id in self._lists: 210 | cls = get_class(self.cls) 211 | path = instance._prefix + instance._id + cls._prefix 212 | data = json.loads(instance._conn.get(path)) 213 | self._lists[instance._id] = [cls(instance._conn, d['id'], d) for d in data] 214 | return self._lists[instance._id] 215 | 216 | 217 | class TrelloMeta(type): 218 | """ 219 | Metaclass for LazyTrello objects, allowing documents to have Field 220 | attributes that know their names without them having to be explicitly 221 | passed to __init__. 222 | """ 223 | def __new__(cls, name, bases, dct): 224 | for k, v in dct.items(): 225 | # For every Field on the class that wasn't initted with an explicit 226 | # 'key', set the field name as the key. 227 | if isinstance(v, Field) and v.key is None: 228 | v.key = k 229 | return super(TrelloMeta, cls).__new__(cls, name, bases, dct) 230 | 231 | 232 | class LazyTrello(object): 233 | """ 234 | Parent class for Trello objects (cards, lists, boards, members, etc). This 235 | should always be subclassed, never used directly. 236 | """ 237 | 238 | __metaclass__ = TrelloMeta 239 | 240 | # The Trello API path where objects of this type may be found. eg '/cards/' 241 | @property 242 | def _prefix(self): 243 | raise NotImplementedError("LazyTrello subclasses MUST define a _prefix") 244 | 245 | def __init__(self, conn, obj_id, data=None): 246 | self._id = obj_id 247 | self._conn = conn 248 | self._path = self._prefix + obj_id 249 | 250 | # If we've been passed the data, then remember it and don't bother 251 | # fetching later. 252 | if data: 253 | self._data = data 254 | 255 | def __getattr__(self, attr): 256 | if attr == '_data': 257 | # Something is trying to access the _data attribute. If we haven't 258 | # fetched data from Trello yet, do so now. Cache the result on the 259 | # object. 260 | if not '_data' in self.__dict__: 261 | self._data = json.loads(self._conn.get(self._path)) 262 | # print(self._data) 263 | 264 | return self._data 265 | else: 266 | raise AttributeError("%r object has no attribute %r" % 267 | (type(self).__name__, attr)) 268 | 269 | def __unicode__(self): 270 | tmpl = u'<%(cls)s: %(name_or_id)s>' 271 | # If I have a name, use that 272 | if 'name' in self._data: 273 | return tmpl % {'cls': self.__class__.__name__, 274 | 'name_or_id': self._data['name']} 275 | 276 | return tmpl % {'cls': self.__class__.__name__, 277 | 'name_or_id': self._id} 278 | 279 | def __str__(self): 280 | return str(self.__unicode__()) 281 | 282 | def __repr__(self): 283 | return str(self.__unicode__()) 284 | 285 | def reload(self): 286 | self.__dict__.pop("_data", None) 287 | 288 | ### BEGIN ACTUAL WRAPPER OBJECTS 289 | 290 | 291 | class Action(LazyTrello): 292 | 293 | _prefix = '/actions/' 294 | data = Field() 295 | type = Field() 296 | date = DateField() 297 | creator = ObjectField('idMemberCreator', 'Member') 298 | 299 | 300 | class Board(LazyTrello, Closable): 301 | 302 | _prefix = '/boards/' 303 | 304 | url = Field('url') 305 | name = Field('name') 306 | pinned = Field() 307 | prefs = Field() 308 | desc = Field() 309 | closed = Field('closed') 310 | 311 | organization = ObjectField('idOrganization', 'Organization') 312 | 313 | actions = SubList('Action') 314 | cards = SubList('Card') 315 | checklists = SubList('Checklist') 316 | lists = SubList('List') 317 | members = SubList('Member') 318 | 319 | # TODO: Generalize this pattern, add it to a base class, and make it work 320 | # correctly with SubList. Until then.... 321 | def add_list(self, name): 322 | path = self._prefix + self._id + '/lists' 323 | body = json.dumps({'name': name, 'idList': self._id, 324 | 'key': self._conn.key, 'token': self._conn.token}) 325 | data = json.loads(self._conn.post(path, body=body)) 326 | new_list = List(self._conn, data['id'], data) 327 | return new_list 328 | 329 | def reload(self): 330 | Board.lists = SubList('List') 331 | 332 | class Card(LazyTrello, Closable, Deletable, Labeled): 333 | 334 | _prefix = '/cards/' 335 | 336 | url = Field('url') 337 | closed = Field('closed') 338 | name = Field('name') 339 | badges = Field('badges') 340 | checkItemStates = Field() 341 | desc = Field('desc') 342 | labels = Field("labels") 343 | 344 | board = ObjectField('idBoard', 'Board') 345 | list = ObjectField('idList', 'List') 346 | 347 | checklists = ListField('idChecklists','Checklist') 348 | members = ListField('idMembers', 'Member') 349 | 350 | def add_comment(self, text): 351 | path = self._path + '/actions/comments' 352 | body = json.dumps({'text': text, 'idCard': self._id, 353 | 'key': self._conn.key, 'token': self._conn.token}) 354 | return self._conn.post(path, body=body) 355 | 356 | def set_position(self, new_position): 357 | path = self._prefix + self._id + '/pos' 358 | body = json.dumps({'value': new_position}) 359 | return self._conn.put(path, body=body) 360 | 361 | def comments(self): 362 | path = self._path + '/actions' 363 | response = self._conn.get(path, dict(filter="commentCard")) 364 | comments_json = json.loads(response) 365 | return [ { 'text': c["data"]["text"], 'username': c["memberCreator"]["username"] } for c in comments_json] 366 | 367 | def move_to_list(self, list): 368 | path = self._prefix + self._id + '/idList' 369 | body = json.dumps({'value': list._id, 370 | 'key': self._conn.key, 'token': self._conn.token}) 371 | return self._conn.put(path, body=body) 372 | 373 | class Checklist(LazyTrello): 374 | 375 | _prefix = '/checklists/' 376 | 377 | checkItems = SubList('CheckItem') 378 | name = Field() 379 | board = ObjectField('idBoard', 'Board') 380 | cards = SubList('Card') 381 | 382 | # TODO: provide a nicer API for checkitems. Figure out where they're 383 | # marked as checked or not. 384 | 385 | # TODO: Figure out why checklists have a /cards/ subpath in the docs. How 386 | # could one checklist belong to multiple cards? 387 | 388 | class CheckItem(LazyTrello): 389 | 390 | _prefix = '/checkItems/' 391 | 392 | name = Field() 393 | pos = Field() 394 | type = Field() 395 | 396 | class List(LazyTrello, Closable): 397 | 398 | _prefix = '/lists/' 399 | 400 | closed = Field('closed') 401 | name = Field('name') 402 | url = Field('url') 403 | board = ObjectField('idBoard', 'Board') 404 | cards = SubList('Card') 405 | 406 | # TODO: Generalize this pattern, add it to a base class, and make it work 407 | # correctly with SubList 408 | def add_card(self, name, desc=None): 409 | path = self._prefix + self._id + '/cards' 410 | body = json.dumps({'name': name, 'idList': self._id, 'desc': desc, 411 | 'key': self._conn.key, 'token': self._conn.token}) 412 | data = json.loads(self._conn.post(path, body=body)) 413 | card = Card(self._conn, data['id'], data) 414 | return card 415 | 416 | def reload(self): 417 | List.board = ObjectField('idBoard', 'Board') 418 | List.cards = SubList('Card') 419 | 420 | class Member(LazyTrello): 421 | 422 | _prefix = '/members/' 423 | 424 | url = 'http://www.trello.com' 425 | fullname = Field('fullName') 426 | username = Field('username') 427 | 428 | actions = SubList('Action') 429 | boards = SubList('Board') 430 | cards = SubList('Card') 431 | notifications = SubList('Notification') 432 | organizations = SubList('Organization') 433 | 434 | # TODO: Generalize this pattern, add it to a base class, and make it work 435 | # correctly with SubList. Until then.... 436 | def add_board(self, name, organization=None, prefs_permissionLevel="private"): 437 | path = '/boards' 438 | body = json.dumps({'name': name, 'prefs_permissionLevel': prefs_permissionLevel, 439 | 'key': self._conn.key, 'token': self._conn.token}) 440 | data = json.loads(self._conn.post(path, body=body)) 441 | board = Board(self._conn, data['id'], data) 442 | return board 443 | 444 | def read_all_notifications(self): 445 | Notification.read_all(self._conn) 446 | 447 | def unread_notifications(self): 448 | return Notification(self._conn, "").unread(self) 449 | 450 | def reload(self): 451 | Member.boards = SubList('Board') 452 | 453 | class Notification(LazyTrello): 454 | 455 | _prefix = '/notifications/' 456 | 457 | data = Field('data') 458 | date = DateField() 459 | type = Field('type') 460 | unread = Field() 461 | 462 | creator = ObjectField('idMemberCreator', 'Member') 463 | 464 | def unread(self, member): 465 | path = member._path + self._prefix 466 | data = json.loads(self._conn.get(path, dict(read_filter="unread"))) 467 | return [Notification(self._conn, notification['id'], notification) for notification in data] 468 | 469 | @classmethod 470 | def read_all(cls, conn): 471 | path = cls._prefix + 'all/read' 472 | body = json.dumps({'value': False, 473 | 'key': conn.key, 'token': conn.token}) 474 | data = json.loads(conn.post(path, body=body)) 475 | return cls 476 | 477 | class Organization(LazyTrello): 478 | 479 | _prefix = '/organizations/' 480 | 481 | url = Field() 482 | desc = Field() 483 | displayname = Field('displayName') 484 | name = Field() 485 | 486 | actions = SubList('Action') 487 | boards = SubList('Board') 488 | members = SubList('Member') 489 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/> 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. -------------------------------------------------------------------------------- /ProjectPlannerTrello.py: -------------------------------------------------------------------------------- 1 | import sublime, sublime_plugin 2 | from subprocess import call 3 | import os, sys, re 4 | from operator import attrgetter 5 | from datetime import datetime, date 6 | from collections import namedtuple, Counter 7 | 8 | from .lib import trollop 9 | from .lib import sublime_requests as requests 10 | from .models import Task, Section, Statistics, DaySlot, human_duration 11 | from .utils import extract_task_metadata 12 | 13 | class ProjectPlannerTrelloUp(sublime_plugin.TextCommand): 14 | 15 | def run(self, edit): 16 | conf = sublime.load_settings('ProjectPlanner.sublime-settings') 17 | self.key = conf.get('TRELLO_API_KEY') 18 | self.token = conf.get("TRELLO_TOKEN") 19 | self.board_id = conf.get("TRELLO_TEST_BOARD_ID") 20 | self.skip_lists = conf.get("SKIP_LISTS") 21 | self.done_lists = conf.get("DONE_LISTS") 22 | self.skip_checklists = conf.get("SKIP_CHECKLISTS") 23 | 24 | self.debug = False 25 | 26 | trello_connection = trollop.TrelloConnection(self.key, self.token) 27 | 28 | try: 29 | self.safe_work(trello_connection, edit) 30 | except Exception as e: 31 | self.show_token_expired_help(e) 32 | raise e 33 | 34 | def show_token_expired_help(self, e): 35 | print("It seems your token is invalid or has expired, try adding it again.\nToken URL: %s" % self.token_url(), "The error encountered was: '%s'" % e) 36 | 37 | def token_url(self): 38 | return "https://trello.com/1/connect?key=%s&name=project_planner&response_type=token&scope=read,write" % self.key 39 | 40 | def __upload_card_order_in_section(self, connection, section): 41 | trello_tasks = filter(lambda task: task.is_trello_card, section.all_tasks) 42 | last_pos = 100 43 | for task in trello_tasks: 44 | print('Set position {} for card {}'.format(last_pos, task.description)) 45 | connection.set_card_position(task.trello_id, last_pos) 46 | last_pos += 100 # be nice with Trello by leaving gaps for reordering 47 | 48 | def __upload_card_order(self, connection, sections): 49 | for section in sections: 50 | self.__upload_card_order_in_section(connection, section) 51 | 52 | def safe_work(self, connection, edit): 53 | content=self.view.substr(sublime.Region(0, self.view.size())) 54 | sections = ProjectPlannerTrello(edit).extract_sections(content) 55 | 56 | self.__upload_card_order(connection, sections) 57 | 58 | class ProjectPlannerTrello(sublime_plugin.TextCommand): 59 | """ 60 | https://github.com/sarumont/py-trello 61 | """ 62 | 63 | HEADING_IDENTIFIER = '#' 64 | SECTION_IDENTIFIER = '## ' 65 | INVALID_SECTIONS = [ 66 | '## Summary', 67 | '## Effort planning', 68 | '## Trello warnings' 69 | ] 70 | 71 | def run(self, edit): 72 | print('Trello plugin run') 73 | conf = sublime.load_settings('ProjectPlanner.sublime-settings') 74 | self.key = conf.get('TRELLO_API_KEY') 75 | self.token = conf.get("TRELLO_TOKEN") 76 | self.board_id = conf.get("TRELLO_TEST_BOARD_ID") 77 | self.skip_lists = conf.get("SKIP_LISTS") 78 | self.done_lists = conf.get("DONE_LISTS") 79 | self.skip_checklists = conf.get("SKIP_CHECKLISTS") 80 | self.debug = False 81 | 82 | trello_connection = trollop.TrelloConnection(self.key, self.token) 83 | 84 | try: 85 | self.safe_work(trello_connection, edit) 86 | except Exception as e: 87 | self.show_token_expired_help(e) 88 | raise e 89 | 90 | def show_token_expired_help(self, e): 91 | print("It seems your token is invalid or has expired, try adding it again.\nToken URL: %s" % self.token_url(), "The error encountered was: '%s'" % e) 92 | 93 | def token_url(self): 94 | return "https://trello.com/1/connect?key=%s&name=project_planner&response_type=token&scope=read,write" % self.key 95 | 96 | 97 | def list_exists(self, list): 98 | trello_section = self.view.find('^## Trello warning', 0) 99 | match = self.view.find(list.name, 0, sublime.LITERAL) 100 | return match.begin() != -1 and match.begin() < trello_section.begin() 101 | 102 | def insert_missing_lists(self, connection, edit): 103 | """ 104 | Insert missing sections in their correct position on the document 105 | """ 106 | board = connection.get_board(self.board_id) 107 | lists = [list for list in board.lists if list.name not in self.skip_lists] 108 | 109 | for index, list in enumerate(lists): 110 | if not self.list_exists(list): 111 | if index == 0: 112 | # Assumes that at least one list already added 113 | ii = index 114 | while True: 115 | next_section = lists[ii+1] 116 | ii += 1 117 | next_insert_pos = self.view.find('## {}'.format(next_section.name), 0, sublime.LITERAL) 118 | if next_insert_pos.begin() != -1: 119 | break 120 | self.view.insert(edit, next_insert_pos.begin(), '## {}\n\n'.format(list.name)) 121 | else: 122 | previous_list = lists[index-1] 123 | prev_section_pos = self.view.find('## {}'.format(previous_list.name), 0, sublime.LITERAL) 124 | next_insert_pos = self.next_section_start(prev_section_pos.end()) 125 | self.view.insert(edit, next_insert_pos, '## {}\n\n'.format(list.name)) 126 | 127 | def find_matching_section(self, list, sections): 128 | 129 | list_name = "## " + list.name 130 | for section in sections: 131 | if section.is_valid and section.title == list_name: 132 | return section 133 | # print(list.name, section.title) 134 | return None 135 | 136 | def find_matching_sections(self, lists, sections): 137 | matches = [] 138 | ListPair = namedtuple('ListPair', ['list', 'section']) 139 | 140 | for list in lists: 141 | section = self.find_matching_section(list, sections) 142 | if section is not None: 143 | matches.append(ListPair(list, section)) 144 | return matches 145 | 146 | def insert_missing_cards(self, cards, section, edit): 147 | section_pos = self.view.find(section.title, 0, sublime.LITERAL) 148 | 149 | if len(section.all_tasks) == 0: 150 | index = self.next_section_start(self.view.find(section.title, 0, sublime.LITERAL).end()) 151 | empty_section = True 152 | else: 153 | last_task_pos = self.view.find(section.all_tasks[-1].raw, section_pos.end(), sublime.LITERAL) 154 | index = last_task_pos.end() # if last_task_pos.end() != -1 else section_pos.end() 155 | empty_section = False 156 | 157 | if index == -1: 158 | print('WARNING: for some reason couldn\'t find location to insert the section {}'.format(section, section.all_tasks[-1])) 159 | 160 | def format_task(card, empty_section): 161 | if empty_section: 162 | return "- [" + card.name + '](' + self.url_core(card.url) + ')\n' 163 | else: 164 | return "\n- [" + card.name + '](' + self.url_core(card.url) + ')' 165 | 166 | # Insert items in Trello order 167 | rev_cards = reversed(cards) 168 | for card in rev_cards: 169 | self.view.insert(edit, index, format_task(card, empty_section)) 170 | 171 | def url_core(self, url): 172 | REGEX = '(?P<url_core>https:\/\/trello.com\/c\/.+\/)(\d+-.+)?' 173 | return re.match(REGEX, url).group('url_core') 174 | 175 | def remove_incorrect_cards(self, tasks, section, edit): 176 | section_pos = self.view.find(section.title, 0, sublime.LITERAL) 177 | for task in tasks: 178 | line = self.view.line(self.view.find(task.raw, section_pos.end(), sublime.LITERAL)) 179 | line.a -= 1 180 | self.view.replace(edit, line, '') 181 | print('Removed task {} from incorrect section {}'.format(task, section.title)) 182 | 183 | def add_missing_cards(self, connection, edit, matches): 184 | def has_match(url, str_array): 185 | for str in str_array: 186 | if url in str: 187 | return True 188 | return False 189 | 190 | 191 | for pair in matches: 192 | card_urls = [card.url for card in pair.list.cards] 193 | missing_cards = [card for card in pair.list.cards if not has_match(self.url_core(card.url), pair.section.lines)] 194 | 195 | incorrect_list_tasks = [task for task in pair.section.all_tasks if task.is_trello_card and not has_match(self.url_core(task.trello_url), card_urls)] 196 | 197 | self.insert_missing_cards(missing_cards, pair.section, edit) 198 | self.remove_incorrect_cards(incorrect_list_tasks, pair.section, edit) 199 | 200 | def next_section_start(self, start=0, delimeter='^##'): 201 | next_section = self.view.find('^##', start).begin() 202 | if next_section == -1: 203 | next_section = self.view.size() 204 | 205 | return next_section 206 | 207 | def update_last_update(self, edit): 208 | heading_region = self.view.find('^## Trello warnings', 0) 209 | if heading_region.begin() == -1: 210 | print('Trello warnings section not found') 211 | return 212 | 213 | line = self.view.line(heading_region) 214 | 215 | next_section_index = self.next_section_start(line.end()) 216 | replace_region = sublime.Region(line.end(), next_section_index) 217 | content = 'Last synced: {}'.format(datetime.now().strftime("%Y-%m-%d %H:%M")) 218 | self.view.replace(edit, replace_region, '\n\n' + content + '\n\n') 219 | 220 | 221 | def __section_indices(self, lines): 222 | SectionIndex = namedtuple('SectionIndex', ['index', 'is_valid']) 223 | indices = [] 224 | for index, line in enumerate(lines): 225 | if line.startswith(self.HEADING_IDENTIFIER): 226 | is_valid_section = line.startswith(self.SECTION_IDENTIFIER) and \ 227 | not line in self.INVALID_SECTIONS 228 | indices.append(SectionIndex(index, is_valid_section)) 229 | indices.append(SectionIndex(len(lines), False)) 230 | 231 | return indices 232 | 233 | def extract_sections(self, content): 234 | # TODO: This is a copy-paste from RoadmapCompile. Extract into another 235 | # module 236 | 237 | array = content.split('\n') 238 | section_indices = self.__section_indices(array) 239 | 240 | sections = [] 241 | 242 | for idx, sec_idx in enumerate(section_indices): 243 | if idx + 1 == len(section_indices): break 244 | start_idx = sec_idx.index 245 | end_idx = section_indices[idx+1].index 246 | 247 | is_section = sec_idx.is_valid 248 | section = Section( 249 | lines = array[start_idx:end_idx], 250 | is_valid = sec_idx.is_valid, 251 | row_at = start_idx 252 | ) 253 | 254 | sections.append(section) 255 | 256 | return sections 257 | 258 | def __compute_checkitem_duration(self, item): 259 | DEFAULT_CATEGORY_DURATION = 8 260 | 261 | meta = extract_task_metadata(item._data['name'])[0] 262 | 263 | resp = {} 264 | 265 | for category, dict in meta.categories.items(): 266 | if not category in resp: 267 | resp[category] = DEFAULT_CATEGORY_DURATION 268 | 269 | if meta.categories[category]['duration_value'][0] is not None and meta.categories[category]['duration_unit'] is not None: 270 | resp[category] = meta.categories[category]['duration_value'][0] * Section.DURATION_MAP[meta.categories[category]['duration_unit']] 271 | 272 | return resp 273 | 274 | def __compute_card_duration(self, checkItems, duration_map, num_checklists, task): 275 | CardDuration = namedtuple('CardDuration', ['category', 'value']) 276 | DEFAULT_CARD_DURATION = 40 277 | COMPLETED_CARD_DURATION = 0 278 | 279 | if len(checkItems) == 0: 280 | if num_checklists > 0: 281 | self.add_error('Possibly Completed Cards', task) 282 | return [CardDuration('None', COMPLETED_CARD_DURATION)] 283 | else: 284 | return [CardDuration('None', DEFAULT_CARD_DURATION)] 285 | 286 | item_durations = {} 287 | for item in checkItems: 288 | durations = self.__compute_checkitem_duration(item) 289 | for key, value in durations.items(): 290 | if not key in item_durations: 291 | item_durations[key] = 0 292 | item_durations[key] += value 293 | 294 | durations = [] 295 | for key, value in item_durations.items(): 296 | temp = CardDuration(key,value) 297 | durations.append(temp) 298 | 299 | return durations 300 | 301 | def __update_card_metadata(self, connection, edit, task, section_title): 302 | CARD_ID_REGEX = '.+https\:\/\/trello\.com\/c\/(?P<card_id>.+)\/.+' 303 | match = re.search(CARD_ID_REGEX, task) 304 | 305 | if not match: 306 | return 307 | 308 | card = connection.get_card(match.group('card_id')) 309 | 310 | if card.closed: 311 | self.add_error('Archived cards', card._data['name']) 312 | 313 | checklists = [checklist for checklist in card.checklists if checklist._data['name'] not in self.skip_checklists] 314 | 315 | incomplete_items = [] 316 | for checklist in checklists: 317 | its = [item for item in checklist.checkItems if item._data['state']=='incomplete'] 318 | incomplete_items += its 319 | 320 | # Filter out cards with the "M"-aybe flag 321 | optional_items = [] 322 | schedulable_items = [] 323 | for item in incomplete_items: 324 | if not '[M ' in item._data['name'] and not '[M]' in item._data['name']: 325 | schedulable_items.append(item) 326 | else: 327 | optional_items.append(item) 328 | 329 | # print('Kept {} sure items from a total of {}'.format(len(schedulable_items), len(incomplete_items))) 330 | 331 | card_name = card.name 332 | card_durations = self.__compute_card_duration(schedulable_items, Section.DURATION_MAP, len(checklists), task) 333 | card_duration_human = '' 334 | 335 | card_durations = sorted(card_durations, key=attrgetter('value', 'category'), reverse=True) 336 | 337 | # Ensure None is the first category in the pipeline 338 | # Then I don't need to print it anymore, making my compiler smarter 339 | # as it will no longer distinguish between None and Non 340 | nonedu = [card for card in card_durations if card.category=="None"] 341 | if len(nonedu) > 0: 342 | card_durations.remove(nonedu[0]) 343 | card_durations = nonedu + card_durations 344 | 345 | for dur in card_durations: 346 | category = '' if dur.category == 'None' else dur.category[:3] 347 | card_duration_human += '{} {} '.format(category, human_duration(dur.value, Section.DURATION_MAP, max_segments=1)) 348 | 349 | card_duration_human = card_duration_human.strip() 350 | deadline = extract_task_metadata(task)[0].end_date 351 | 352 | if deadline: 353 | new_meta = '[{} {}]'.format(card_duration_human, deadline.strftime("%Y-%m-%d")) 354 | elif card_duration_human: 355 | new_meta = '[{}]'.format(card_duration_human) 356 | elif len(optional_items) > 0: 357 | new_meta = '[M]' 358 | else: 359 | new_meta = '' 360 | 361 | section_pos = self.view.find(section_title, 0, sublime.LITERAL) 362 | task_pos = self.view.find(task, section_pos.end(), sublime.LITERAL) 363 | 364 | # Update name 365 | end_name_pos = self.view.find(']', task_pos.begin(), sublime.LITERAL) 366 | region = sublime.Region(task_pos.begin() + 3, end_name_pos.begin()) 367 | self.view.replace(edit, region, card_name) 368 | 369 | # Use shorter trello url (to prevent name clashes) 370 | start_pos = self.view.find(']', task_pos.begin(), sublime.LITERAL) 371 | end_pos = self.view.find(')', start_pos.end(), sublime.LITERAL) 372 | region = sublime.Region(start_pos.end() + 1, end_pos.begin()) 373 | self.view.replace(edit, region, self.url_core(card.url)) 374 | 375 | # Update meta 376 | line = self.view.line(task_pos.begin()) 377 | needs_update = self.view.substr(line).strip()[-1] == ']' 378 | 379 | if needs_update: 380 | update_pos = self.view.find('[', task_pos.begin() + 4, sublime.LITERAL) 381 | update_reg = sublime.Region(update_pos.begin(), line.end()) 382 | self.view.replace(edit, update_reg, new_meta) 383 | else: 384 | self.view.insert(edit, line.end(), new_meta) 385 | 386 | def __update_card_section_metadata(self, connection, edit, tasks, section_title): 387 | max_tasks = 1 388 | for task in tasks: 389 | self.__update_card_metadata(connection, edit, task, section_title) 390 | max_tasks -= 1 391 | 392 | if self.debug: 393 | print(task) 394 | 395 | if self.debug and max_tasks == 0: 396 | print('Stopped after %d tasks for development' % max_tasks) 397 | break 398 | 399 | def add_error(self, category, error): 400 | exists = [err for err in self.errors if err['category'] == category] 401 | 402 | if exists: 403 | exists[0]['errors'].append(error) 404 | else: 405 | self.errors.append({ 406 | 'category': category, 407 | 'errors': [error] 408 | }) 409 | 410 | def update_cards_metadata(self, connection, edit, matches): 411 | 412 | for pair in matches: 413 | tasks = [task.raw for task in pair.section.tasks] 414 | self.__update_card_section_metadata(connection, edit, tasks, pair.section.title) 415 | 416 | if self.debug: 417 | break 418 | 419 | def display_errors(self, edit): 420 | heading_region = self.view.find('^### Errors', 0) 421 | if heading_region.begin() == -1: 422 | print('Errors section not found') 423 | return 424 | 425 | line = self.view.line(heading_region) 426 | 427 | next_section_index = self.next_section_start(line.end()) 428 | 429 | replace_region = sublime.Region(line.end(), next_section_index) 430 | 431 | if len(self.errors) == 0: 432 | content = 'There are no errors\n\n' 433 | else: 434 | content = '' 435 | for errorgroup in self.errors: 436 | content += '**{}**:\n'.format(errorgroup['category']) 437 | for error in errorgroup['errors']: 438 | content += '- {}\n'.format(error) 439 | content += '\n' 440 | 441 | self.view.replace(edit, replace_region, '\n\n' + content + '') 442 | # content = 'Last updated: {}'.format(datetime.now().strftime("%Y-%m-%d")) 443 | 444 | def warn_incorrect_list_order(self, lists, sections): 445 | list_titles = [list._data['name'] for list in lists] 446 | section_titles = [section.title[3:] for section in sections if section.is_valid] 447 | 448 | indices = [] 449 | for list_title in list_titles: 450 | try: 451 | indices.append(section_titles.index(list_title)) 452 | except: 453 | indices.append(-1) 454 | 455 | for index_idx, index in enumerate(indices): 456 | if index_idx > 0: 457 | if indices[index_idx - 1] > index: 458 | self.add_error('List ordering', '*{}* should be placed before *{}*'.format(list_titles[index_idx-1], list_titles[index_idx])) 459 | 460 | def mark_completed(self, sections, edit, done_lists): 461 | """ 462 | Mark as completed each card in the DONE list 463 | """ 464 | 465 | def find_in_section(card, section_title): 466 | title_idx = self.view.find(section_title, 0) 467 | next_section = self.next_section_start(title_idx.end()) 468 | card_idx = self.view.find(self.url_core(card.url), title_idx.end()) 469 | if card_idx.end() > 0 and card_idx.end() < next_section: 470 | return self.view.line(card_idx.begin()) 471 | else: 472 | return None 473 | 474 | completed_cards = [card for list in done_lists for card in list.cards] 475 | section_titles = [section.title for section in sections if section.is_valid] 476 | for section_title in section_titles: 477 | for completed_card in completed_cards: 478 | card_line = find_in_section(completed_card, section_title) 479 | if card_line: 480 | replace_region = sublime.Region(card_line.begin(), card_line.begin() + 1) 481 | self.view.replace(edit, replace_region, '+') 482 | 483 | def safe_work(self, connection, edit): 484 | 485 | self.errors = [] 486 | self.debug = False 487 | 488 | if self.debug: 489 | print("DEBUG MODE IS ON") 490 | 491 | content=self.view.substr(sublime.Region(0, self.view.size())) 492 | sections = self.extract_sections(content) 493 | 494 | self.insert_missing_lists(connection, edit) 495 | 496 | # To enlist newly added sections 497 | content=self.view.substr(sublime.Region(0, self.view.size())) 498 | sections = self.extract_sections(content) 499 | 500 | board = connection.get_board(self.board_id) 501 | lists = [list for list in board.lists if list.name not in self.skip_lists] 502 | done_lists = [list for list in board.lists if list.name in self.done_lists] 503 | matches = self.find_matching_sections(lists, sections) 504 | 505 | self.warn_incorrect_list_order(lists, sections) 506 | self.add_missing_cards(connection, edit, matches) 507 | 508 | # REcreate the secitons and matches, since that's where they generate their tasks 509 | content=self.view.substr(sublime.Region(0, self.view.size())) 510 | sections = self.extract_sections(content) 511 | matches = self.find_matching_sections(lists, sections) 512 | 513 | self.update_cards_metadata(connection, edit, matches) 514 | self.mark_completed(sections, edit, done_lists) 515 | self.display_errors(edit) 516 | self.update_last_update(edit) 517 | -------------------------------------------------------------------------------- /ProjectPlanner.py: -------------------------------------------------------------------------------- 1 | import re, os, sys 2 | from datetime import timedelta, datetime, date 3 | from collections import namedtuple, Counter 4 | import operator 5 | import math 6 | from operator import attrgetter, methodcaller, itemgetter 7 | from time import gmtime, strftime 8 | import random 9 | import sublime, sublime_plugin 10 | import mdpopups 11 | from .models import Task, Section, Statistics, DaySlot 12 | from .models import human_duration 13 | from .utils import sparkline, truncate_middle, weeknumber, fmtweek 14 | from .utils import next_available_weekday, human_duration, weighted_sampling_without_replacement 15 | from .utils import listdiff 16 | 17 | class ProjectPlannerCompile(sublime_plugin.TextCommand): 18 | HEADING_IDENTIFIER = '#' 19 | SECTION_IDENTIFIER = '## ' 20 | INVALID_SECTIONS = [ 21 | '## Trello warnings', 22 | '## Plan:*', 23 | ] 24 | 25 | def __section_indices(self, lines): 26 | SectionIndex = namedtuple('SectionIndex', ['index', 'is_valid']) 27 | indices = [] 28 | 29 | def is_section_valid(line): 30 | if not line.startswith(self.SECTION_IDENTIFIER): 31 | return False 32 | for invalid_section in self.INVALID_SECTIONS: 33 | if re.match(invalid_section, line): 34 | return False 35 | return True 36 | 37 | for index, line in enumerate(lines): 38 | if line.startswith(self.HEADING_IDENTIFIER): 39 | indices.append(SectionIndex(index, is_section_valid(line))) 40 | indices.append(SectionIndex(len(lines), False)) 41 | 42 | return indices 43 | 44 | 45 | def _extract_sections(self, content): 46 | 47 | array = content.split('\n') 48 | section_indices = self.__section_indices(array) 49 | 50 | sections = [] 51 | 52 | for idx, sec_idx in enumerate(section_indices): 53 | if idx + 1 == len(section_indices): break 54 | start_idx = sec_idx.index 55 | end_idx = section_indices[idx+1].index 56 | 57 | is_section = sec_idx.is_valid 58 | section = Section( 59 | lines = array[start_idx:end_idx], 60 | is_valid = sec_idx.is_valid, 61 | row_at = start_idx 62 | ) 63 | 64 | sections.append(section) 65 | 66 | return sections 67 | 68 | def _compute_total_weekly_load(self, section, statistics, for_weeks=40, quarter_breaks=False): 69 | tasks = [task for task in section.tasks] 70 | categorized_effort = self.__compute_weekly_load(tasks, statistics) 71 | 72 | total_effort = {} 73 | for key in categorized_effort: 74 | for week in categorized_effort[key]['effort']: 75 | if not week in total_effort: 76 | total_effort[week] = 0 77 | total_effort[week] += categorized_effort[key]['effort'][week] 78 | 79 | weekly_efforts = [] 80 | QUARTER_CHANGE_DELIMETER = None 81 | prev_quarter = None 82 | for x in range(for_weeks): 83 | dt = date.today() + timedelta(weeks=x) 84 | new_quarter = math.ceil(dt.month / 3.) 85 | if quarter_breaks and prev_quarter and prev_quarter != new_quarter: 86 | weekly_efforts.append(QUARTER_CHANGE_DELIMETER) 87 | week = fmtweek(dt) 88 | week_eff = total_effort[week] if week in total_effort else 0 89 | weekly_efforts.append(week_eff) 90 | prev_quarter = new_quarter 91 | 92 | return weekly_efforts 93 | 94 | def _update_section_timings(self, sections, edit, statistics): 95 | """ 96 | Below each section write a short summary of number of tasks and 97 | planned durations 98 | """ 99 | SPARK_START = "⌚" 100 | last_point = 0 101 | for section in sections: 102 | if section.is_valid: 103 | weekly_load = self._compute_total_weekly_load(section, statistics, quarter_breaks=self.show_quarters) 104 | spark = sparkline(weekly_load) 105 | 106 | if section.needs_update: 107 | content = section.summary + '\n' + SPARK_START + spark 108 | line = self.view.line(self.view.find(section.lines[1], last_point, sublime.LITERAL)) 109 | next_line = self.view.line(line.end() + 1) 110 | self.view.replace(edit, sublime.Region(line.begin(), next_line.end()), content) 111 | else: 112 | content = '\n' + section.summary + '\n' + SPARK_START + spark 113 | line = self.view.find(section.lines[0], last_point, sublime.LITERAL) 114 | self.view.insert(edit, line.end(), content) 115 | last_point = line.end() 116 | 117 | def _update_upcoming_tasks(self, sections, edit, statistics): 118 | """ 119 | Print the top upcoming tasks in the `## Plan: Upcoming tasks` section. 120 | """ 121 | DEFAULT_NUM_TASKS = 10 122 | NUM_TASKS_PER_CATEGORY = 5 123 | UPCOMING_TASKS_SECTION_REGEX = '## Plan: (\d+\s)?[Uu]pcoming tasks' 124 | SHOW_TASKS_BY_CATEGORY = True 125 | 126 | sections = [section for section in sections if section.weight > 0] 127 | 128 | index_section = self.view.find(UPCOMING_TASKS_SECTION_REGEX, 0) 129 | if index_section.begin() == -1: 130 | # Upcoming tasks section is not wanted. Stop. 131 | return 132 | 133 | def upcoming_cat_task_group_content(task_group, num_tasks, category): 134 | sorted_tasks = sorted(task_group.tasks, key=methodcaller('scheduled_start_date', category)) 135 | 136 | sorted_tasks_string = '\n\n### ' + task_group.title + ' upcoming tasks\n\n' if task_group.show_title else '' 137 | sorted_tasks_string += '\n'.join([str(task) for task in sorted_tasks[:num_tasks]]) 138 | 139 | if len(sorted_tasks) == 0: 140 | sorted_tasks_string += 'There are not tasks in this category' 141 | 142 | return sorted_tasks_string 143 | 144 | line = self.view.line(index_section) 145 | section_title = self.view.substr(line) 146 | match = re.search('(?P<num_tasks>\d+)', section_title) 147 | 148 | default_num_tasks = int(match.group('num_tasks')) if match else DEFAULT_NUM_TASKS 149 | 150 | nested_tasks = [section.tasks for section in sections] 151 | 152 | UpcomingTaskGroup = namedtuple('UpcomingTaskGroup', ['title', 'tasks', 'show_title']) 153 | 154 | all_tasks = [task for tasks in nested_tasks for task in tasks if task.is_mandatory] 155 | upcoming_task_groups = [ 156 | UpcomingTaskGroup( 157 | show_title = False, 158 | title = 'All', 159 | tasks = all_tasks 160 | ) 161 | ] 162 | 163 | for category in statistics.categories: 164 | upcoming_task_groups.append(UpcomingTaskGroup( 165 | title = category if category else 'Uncategorized', 166 | show_title = True, 167 | tasks = list(filter(lambda task: task.has_category(category), all_tasks)) 168 | )) 169 | 170 | upcoming_task_groups.append(UpcomingTaskGroup( 171 | title = 'Deadlined', 172 | show_title = True, 173 | tasks = list(filter(lambda task: task.has_deadline, all_tasks)) 174 | )) 175 | 176 | all_task_groups_content = [] 177 | for task_group in upcoming_task_groups: 178 | num_tasks = NUM_TASKS_PER_CATEGORY if task_group.show_title else default_num_tasks 179 | all_task_groups_content.append(upcoming_cat_task_group_content(task_group, num_tasks, task_group.title)) 180 | 181 | next_section_index = self.view.find('^## ', line.end()).begin() 182 | replace_region = sublime.Region(line.end(), next_section_index) 183 | self.view.replace(edit, replace_region, '\n\n' + ''.join(all_task_groups_content) + '\n\n') 184 | 185 | def _content_for_total_effort_chart(self, sections): 186 | durations = [section.duration[2] for section in sections] 187 | summed_durations = sum( 188 | (Counter(dict(x)) for x in durations), 189 | Counter()) 190 | max_key_length = max([len(key) for key in summed_durations.keys()]) 191 | max_value = max([value for value in summed_durations.values()]) 192 | sorted_summed_durations = sorted(summed_durations.items(), key=itemgetter(1), reverse=True) 193 | 194 | durations_chart = [] 195 | scale_factor = 30/max_value 196 | for category, duration in sorted_summed_durations: 197 | hum_duration = human_duration(duration, Section.DURATION_MAP, max_segments=2) 198 | chart_format = "%" + str(max_key_length) + "s %6s %s" 199 | chart_row = chart_format % (category, hum_duration, "#" * int(duration * scale_factor)) 200 | durations_chart.append(chart_row) 201 | 202 | effort_content = '```\n' + '\n'.join(durations_chart) + '\n```' 203 | return effort_content 204 | 205 | def _update_planned_effort(self, sections, edit, statistics): 206 | 207 | 208 | heading_region = self.view.find('^## Plan: Total estimated effort', 0) 209 | if heading_region.begin() == -1: 210 | return 211 | 212 | effort_content = self._content_for_total_effort_chart(sections) 213 | 214 | line = self.view.line(heading_region) 215 | next_section_index = self.view.find('^##', line.end()).begin() 216 | replace_region = sublime.Region(line.end(), next_section_index) 217 | self.view.replace(edit, replace_region, '\n\n' + effort_content + '\n\n') 218 | 219 | def _compute_statistics(self, sections): 220 | """ 221 | Computes statistics to avoid computing it several times later 222 | """ 223 | 224 | return Statistics(sections) 225 | 226 | def _estimate_missing_data(self, sections, stats): 227 | """ 228 | Fill-in the gaps: task duration. 229 | """ 230 | for section in sections: 231 | for task in section.tasks: 232 | for category in task.categories(): 233 | if not task.category_duration(category, fake_valid=False): 234 | task.set_fake_duration(category, stats.get_mean_duration(category)) 235 | 236 | def _schedule_task_with_deadline(self, task, available_before_date, available_effort, max_effort, category): 237 | """ 238 | The scheduler is only precise to the day, 239 | but will make sure you nevere have more than max_effort hours in 240 | any single day 241 | """ 242 | MONDAY=0 243 | FRIDAY=4 244 | SATURDAY=5 245 | SUNDAY=6 246 | 247 | if available_effort <= 0: 248 | available_effort += max_effort 249 | available_before_date -= timedelta(days=1) 250 | 251 | # Define end_date 252 | if task.meta.end_date is not None and task.meta.end_date < available_before_date: 253 | end_date = task.meta.end_date 254 | available_effort = max_effort 255 | else: 256 | # print('SCHEDULE INFO: Task %s will have to begin earlier due to later tasks taking long' % (task,)) 257 | end_date = available_before_date 258 | 259 | # Skip saturday & sunday 260 | if end_date.weekday() == SATURDAY: 261 | end_date -= timedelta(days=1) 262 | elif end_date.weekday() == SUNDAY: # this should never really happen 263 | end_date -= timedelta(days=2) 264 | 265 | if end_date < datetime.today(): 266 | self.add_error('Past deadline', '"{}" ({}) should have been completed by {}'.format(task.description, category, end_date.date())) 267 | 268 | duration = int(task.category_duration(category)) 269 | 270 | 271 | slots = [] 272 | cur_dt = end_date 273 | while duration > 0: 274 | block_duration = min(available_effort, duration) 275 | slot = DaySlot(cur_dt, block_duration) 276 | slots.append(slot) 277 | available_effort -= block_duration 278 | duration -= block_duration 279 | if available_effort == 0: 280 | cur_dt = next_available_weekday(cur_dt) 281 | available_effort = max_effort 282 | 283 | task.set_slots_for_category(category, slots) 284 | 285 | return (cur_dt, available_effort) 286 | 287 | def _schedule_preconditioned_task(self, task, first_available_date, max_effort, category, all_tasks, prev_deadlined_task, next_deadlined_task): 288 | 289 | print('Place "{} after "{}" but before "{}" in category "{}"'.format(task.name, prev_deadlined_task, next_deadlined_task, category)) 290 | return first_available_date 291 | 292 | def _schedule_task_wout_deadline(self, task, first_available_date, max_effort, category, all_tasks, completed_before_date=None): 293 | MONDAY=0 294 | FRIDAY=4 295 | SATURDAY=5 296 | SUNDAY=6 297 | 298 | duration = task.category_duration(category) 299 | 300 | # Don't plan work for weekends 301 | if first_available_date.weekday() == SATURDAY: 302 | first_available_date += timedelta(days=2) 303 | elif first_available_date.weekday() == SUNDAY: # this should never really happen 304 | first_available_date += timedelta(days=1) 305 | 306 | def next_available_weekday(dt): 307 | MONDAY=0 308 | FRIDAY=4 309 | SATURDAY=5 310 | SUNDAY=6 311 | if dt.weekday() == FRIDAY: 312 | delta = timedelta(days=3) 313 | elif dt.weekday() == SATURDAY: 314 | delta = timedelta(days=2) 315 | else: 316 | delta = timedelta(days=1) 317 | return dt + delta 318 | 319 | 320 | def available_effort(all_tasks, max_effort, cur_dt, category): 321 | 322 | def slots_of_day(slot, dt): 323 | return slot.date.date() == dt.date() 324 | 325 | cur_dt_date = cur_dt.date() 326 | 327 | day_slots = [] 328 | for task in all_tasks: 329 | for slot in task.get_slots_for_category(category): 330 | if slot.date.date() == cur_dt_date: 331 | day_slots.append(slot) 332 | break 333 | 334 | allocated_effort = sum([slot.hours for slot in day_slots]) 335 | return max_effort - allocated_effort 336 | 337 | slots = [] 338 | cur_dt = first_available_date 339 | while duration > 0: 340 | remaing_effort = available_effort(all_tasks, max_effort, cur_dt, category) 341 | if remaing_effort == 0: 342 | cur_dt = next_available_weekday(cur_dt) 343 | continue 344 | 345 | allocate_effort = min(remaing_effort, duration) 346 | 347 | slot = DaySlot(cur_dt, int(allocate_effort)) 348 | slots.append(slot) 349 | duration -= allocate_effort 350 | 351 | if duration > 0: 352 | cur_dt = next_available_weekday(cur_dt) 353 | 354 | if completed_before_date is not None and completed_before_date < cur_dt: 355 | self.add_error('Prerequirement mismatch', '{}: "{}" should have been completed before {}. Instead it will be done by {}'.format(category, task.description, task.prerequirement_for_deadlined, cur_dt.date())) 356 | 357 | task.set_slots_for_category(category, slots) 358 | 359 | # FIXME: It it be returning the cur_dt? 360 | return first_available_date 361 | 362 | 363 | def _prioritize_tasks(self, tasks_wout_deadline, stats): 364 | """ 365 | reoader tasks_wout_deadline based on the section probabilitisc 366 | weights 367 | """ 368 | 369 | def adjusted_section_weights(sections, section_countdown): 370 | weights = [] 371 | for section in sections: 372 | weight = section.weight if section_countdown[section.title] > 0 else 0 373 | weights.append((weight, section)) 374 | return weights 375 | 376 | sections = set([task.section for task in tasks_wout_deadline]) 377 | sections = sorted(list(sections)) # order the set, for limited randomness 378 | 379 | 380 | section_tasks = {} 381 | section_countdown = {} 382 | # tot_tasks = 0 383 | for section in sections: 384 | section_tasks[section.title] = [task for task in section.tasks if task.is_mandatory and task in tasks_wout_deadline] 385 | section_countdown[section.title] = len(section_tasks[section.title]) 386 | # tot_tasks += len(section_tasks[section.title]) 387 | # assert(len(tasks_wout_deadline) == tot_tasks) 388 | prioritized_tasks = [] 389 | weighted_sections = adjusted_section_weights(sections, section_countdown) 390 | 391 | def get_next_task_in_section(section, rem_tsks, section_countdown): 392 | valid_tasks = section_tasks[section.title] 393 | return valid_tasks[len(valid_tasks) - section_countdown[section.title]] 394 | 395 | remaining_tasks = list(tasks_wout_deadline) 396 | while len(remaining_tasks) > 0: 397 | myrandom = random.Random(self.myrandomseed) 398 | section = weighted_sampling_without_replacement(weighted_sections, 1, myrandom)[0][1] 399 | task = get_next_task_in_section(section, remaining_tasks, section_countdown) 400 | # print('Selecting task %s from %s' % (task, section)) 401 | prioritized_tasks.append(task) 402 | remaining_tasks.remove(task) 403 | section_countdown[task.section.title] -= 1 404 | weighted_sections = adjusted_section_weights(sections, section_countdown) 405 | 406 | return prioritized_tasks 407 | 408 | def _check_correct_deadlined_task_ordering(self, tasks, category): 409 | # group tasks by group 410 | sections = list(set([t.section for t in tasks])) 411 | 412 | for section in sections: 413 | section_tasks = [t for t in tasks if t.section == section] 414 | 415 | for i in range(len(section_tasks) - 1): 416 | if section_tasks[i].meta.end_date > section_tasks[i+1].meta.end_date: 417 | self.add_error( 418 | 'Incorrect ordering of tasks with deadline', 419 | '{}: Task *{}* with deadline {} should be placed after task *{}* with deadline {} '.format( 420 | section.pretty_title, 421 | section_tasks[i].description, 422 | section_tasks[i].meta.end_date.date(), 423 | section_tasks[i+1].description, 424 | section_tasks[i+1].meta.end_date.date() 425 | ) 426 | ) 427 | 428 | def _compute_schedule_for_category(self, tasks, category, stats): 429 | """ 430 | End date is understood such, that max_load can be done also on that day 431 | 432 | Steps: 433 | 1. Place all deadlined tasks the latest possible - ensure deadlines OK 434 | 2. Place all prerequisites the soonest possible - ensure deadlines OK 435 | 3. Place everything else based on priorities - play with fire 436 | 437 | Thus 2 levels of errors: 438 | CRITICAL: deadlined task cannot be finished 439 | SEVERE: preconditioned task cannot be finished 440 | """ 441 | 442 | 443 | max_load = stats.max_load_for_category(category) 444 | last_available_date = datetime(2999, 12, 12) 445 | remaing_effort = max_load 446 | tasks_w_deadline = [t for t in tasks if t.meta.end_date is not None] 447 | 448 | self._check_correct_deadlined_task_ordering(tasks_w_deadline, category) 449 | 450 | tasks_w_deadline = sorted(tasks_w_deadline, key=attrgetter('meta.end_date'), reverse=True) 451 | tasks_wout_deadline = list(filter(lambda t: t.meta.end_date is None, tasks)) 452 | tasks_wout_deadline = self._prioritize_tasks(tasks_wout_deadline, stats) 453 | tasks_preconditioned = [] 454 | 455 | num_tasks_w_deadline = len(tasks_w_deadline) 456 | 457 | for task in tasks_w_deadline: 458 | (last_available_date, remaing_effort) = self._schedule_task_with_deadline(task, last_available_date, remaing_effort, max_load, category) 459 | 460 | # Step 2: Place all prerequisites the soonest possible 461 | # First, find all prerequisites tasks 462 | for task in tasks_wout_deadline: 463 | prerequirement_for = self._find_next_deadlined_task(task, category) 464 | if prerequirement_for: 465 | task.prerequirement_for_deadlined = prerequirement_for 466 | task.depends_on_deadlined = self._find_prev_deadlined_task(task, category) 467 | tasks_preconditioned.append(task) 468 | # print('{}: {} < {} < {}'.format(category, depends_on, task, prerequirement_for)) 469 | 470 | # Second, schedule them 471 | tasks_preconditioned = self._prioritize_tasks(tasks_preconditioned, stats) 472 | first_available_date = datetime.combine(date.today(), datetime.min.time()) 473 | remaing_effort = max_load # assume start at 0 (to avoid modifying schedule during the day) 474 | for task in tasks_preconditioned: 475 | after = first_available_date if task.depends_on_deadlined is None else task.depends_on_deadlined.scheduled_end_date(category) 476 | 477 | before = None if task.prerequirement_for_deadlined is None else task.prerequirement_for_deadlined.scheduled_start_date(category) # end of day 478 | new_first_available_date = self._schedule_task_wout_deadline(task, after, max_load, category, tasks, before) 479 | first_available_date = new_first_available_date if task.depends_on_deadlined is None else first_available_date 480 | 481 | # Step 3: Place all remaining tasks based on priorities 482 | tasks_wout_deadline = listdiff(tasks_wout_deadline, tasks_preconditioned) 483 | remaing_effort = max_load # assume start at 0 (to avoid modifying schedule during the day) 484 | for task in tasks_wout_deadline: 485 | first_available_date = self._schedule_task_wout_deadline(task, first_available_date, max_load, category, tasks) 486 | 487 | def _find_prev_deadlined_task(self, task, category): 488 | prev_deadlined_tasks = [t for t in task.section.tasks if t.meta.end_date and t.pos < task.pos and t.has_category(category)] 489 | if len(prev_deadlined_tasks) > 0: 490 | return prev_deadlined_tasks[-1] 491 | else: 492 | return None 493 | 494 | def _find_next_deadlined_task(self, task, category): 495 | next_deadlined_tasks = [t for t in task.section.tasks if t.meta.end_date and t.pos > task.pos and t.has_category(category)] 496 | if len(next_deadlined_tasks) > 0: 497 | return next_deadlined_tasks[0] 498 | else: 499 | return None 500 | 501 | def _compute_schedule(self, sections, statistics): 502 | sections = [section for section in sections if section.weight > 0] 503 | 504 | nested_tasks = map(lambda section: section.tasks, sections) 505 | all_tasks = [task for tasks in nested_tasks for task in tasks] 506 | 507 | schedules = [] 508 | for category in statistics.categories: 509 | category_tasks = [t for t in all_tasks if t.has_category(category)] 510 | self._compute_schedule_for_category(category_tasks, category, statistics) 511 | 512 | def _fold_links(self): 513 | startMarker = "](" 514 | endMarker = ")" 515 | startpos = self.view.find_all(startMarker, sublime.LITERAL) 516 | endpos = [] 517 | 518 | validstartpos = [] 519 | 520 | for x in range(len(startpos)): 521 | # Try to find end marker in the same line 522 | line = self.view.line(startpos[x]) 523 | found = self.view.find(endMarker, startpos[x].end(), sublime.LITERAL) 524 | if line.end() >= found.end(): 525 | validstartpos.append(startpos[x]) 526 | endpos.append(found) 527 | 528 | regions = [] 529 | for x in range(len(endpos)): 530 | regions.append(sublime.Region(validstartpos[x].end(), endpos[x].begin())) 531 | 532 | self.view.unfold(sublime.Region(0, self.view.size())) 533 | self.view.fold(regions) 534 | 535 | def _mark_date_completed(self, sections, edit): 536 | DATE_MARKER = "@done" 537 | STRIKE = "~~" 538 | for section in sections: 539 | for task in section.completed_tasks(): 540 | if task.find(DATE_MARKER) == -1: 541 | end_pos = self.view.find(task, 0, sublime.LITERAL).end() 542 | formatted_marker = " %s(%s)" % (DATE_MARKER, date.today()) 543 | self.view.insert(edit, end_pos, formatted_marker) 544 | if task.find(STRIKE) == -1: 545 | task_region = self.view.line(self.view.find(task, 0, sublime.LITERAL)) 546 | self.view.insert(edit, task_region.begin() + 2, STRIKE) 547 | self.view.insert(edit, task_region.end() + 2, STRIKE) 548 | # formatted_marker = " %s(%s)" % (DATE_MARKER, date.today()) 549 | 550 | def __compute_weekly_load(self, tasks, statistics): 551 | 552 | effort = {} 553 | for category in statistics.categories: 554 | cat_effort = { 555 | 'effort': {} 556 | } 557 | cat_tasks = [task for task in tasks if task.has_category(category)] 558 | 559 | for task in cat_tasks: 560 | for slot in task.get_slots_for_category(category): 561 | week = fmtweek(slot.date) 562 | if not week in cat_effort['effort']: 563 | cat_effort['effort'][week] = 0 564 | 565 | cat_effort['effort'][week] += slot.hours 566 | 567 | effort[str(category)]=cat_effort 568 | return effort 569 | 570 | def _draw_weekly_schedule(self, sections, edit, statistics): 571 | 572 | heading_region = self.view.find('^## Plan: (\d+w? )?Week(.+) effort timeline', 0) 573 | if heading_region.begin() == -1: 574 | return 575 | 576 | line = self.view.line(heading_region) 577 | 578 | match = re.search('(?P<num_weeks>\d+)', self.view.substr(line)) 579 | for_weeks = int(match.group('num_weeks')) if match else 10 580 | 581 | nested_tasks = map(lambda section: section.tasks, sections) 582 | all_tasks = [task for tasks in nested_tasks for task in tasks] 583 | 584 | effort = self.__compute_weekly_load(all_tasks, statistics) 585 | 586 | max_weekly_effort = 0 587 | for key in effort: 588 | for week in effort[key]['effort']: 589 | max_weekly_effort = max(max_weekly_effort, effort[key]['effort'][week]) 590 | 591 | 592 | effort_content = '{:<7} '.format('') 593 | effort_content += "".join(["{:<5} ".format(cat) for cat in statistics.categories ]) + '\n' 594 | 595 | max_chars = 5 596 | for x in range(for_weeks): 597 | dt = date.today() + timedelta(weeks=x) 598 | week = fmtweek(dt) 599 | effort_content += '{:<7} '.format(week) 600 | for category in statistics.categories: 601 | if week in effort[category]['effort']: 602 | week_eff = '|' * (round(effort[category]['effort'][week] / max_weekly_effort * max_chars)) 603 | else: 604 | week_eff = '' 605 | effort_content += '{:<5} '.format(week_eff) 606 | effort_content += '\n' 607 | 608 | next_section_index = self.view.find('^##', line.end()).begin() 609 | replace_region = sublime.Region(line.end(), next_section_index) 610 | self.view.replace(edit, replace_region, '\n\n```\n' + effort_content + '```\n\n') 611 | 612 | def _draw_section_schedule(self, sections, edit, statistics, to_scale=False): 613 | 614 | heading_region = self.view.find('^## Plan: (\d+w )?[Ss]ection schedule', 0) 615 | if heading_region.begin() == -1: 616 | return 617 | 618 | line = self.view.line(heading_region) 619 | 620 | match = re.search('(?P<num_weeks>\d+).+', self.view.substr(line)) 621 | for_weeks = int(match.group('num_weeks')) if match else 30 622 | for_weeks = min(for_weeks, 60) 623 | 624 | match = re.search('.+(?P<to_scale>to scale)\s*', self.view.substr(line)) 625 | to_scale = True if match and match.group('to_scale') else to_scale 626 | 627 | data = [] 628 | smallest = 0 629 | largest = 40 630 | for section in sections: 631 | if section.is_valid: 632 | weekly_load = self._compute_total_weekly_load(section, statistics, for_weeks=for_weeks, quarter_breaks=self.show_quarters) 633 | largest = max(largest, max([w for w in weekly_load if w is not None])) 634 | data.append((weekly_load, section.title[3:])) 635 | 636 | MAX_WIDTH = 76 637 | title_width = MAX_WIDTH - for_weeks - 1 638 | title_width -= len([w for w in weekly_load if w is None]) 639 | 640 | fmt_string = '{:<' + str(title_width) + '} {}\n' 641 | 642 | if not to_scale: 643 | largest = 40 644 | 645 | effort_content = '' 646 | for x in range(len(data)): 647 | weekly_load, section_title = data[x] 648 | spark = sparkline(weekly_load, smallest=smallest, largest=largest) 649 | effort_content += fmt_string.format(truncate_middle(section_title, title_width), spark) 650 | 651 | next_section_index = self.view.find('^##', line.end()).begin() 652 | replace_region = sublime.Region(line.end(), next_section_index) 653 | self.view.replace(edit, replace_region, '\n\n```\n' + effort_content + '```\n\n') 654 | 655 | def add_error(self, category, error): 656 | exists = [err for err in self.errors if err['category'] == category] 657 | 658 | if exists: 659 | exists[0]['errors'].append(error) 660 | else: 661 | self.errors.append({ 662 | 'category': category, 663 | 'errors': [error] 664 | }) 665 | 666 | def _errors_content(self): 667 | content = '' 668 | if len(self.errors) > 0: 669 | content = '\n\nThere are errors in your plan:\n\n' 670 | for errorgroup in self.errors: 671 | content += '*{}*:\n'.format(errorgroup['category']) 672 | for error in errorgroup['errors']: 673 | content += '- {}\n'.format(error) 674 | content += '\n' 675 | else: 676 | content = '\n\n' 677 | 678 | return content 679 | 680 | def _update_timestamp_and_errors(self, edit): 681 | 682 | heading_region = self.view.find('## Plan: Information', 0, sublime.LITERAL) 683 | 684 | if heading_region.begin() == -1: 685 | return 686 | 687 | line = self.view.line(heading_region) 688 | 689 | next_section_index = self.view.find('^##', line.end()).begin() 690 | replace_region = sublime.Region(line.end(), next_section_index) 691 | content = 'Last updated: {}'.format(datetime.now().strftime("%Y-%m-%d")) 692 | 693 | content += self._errors_content() 694 | 695 | self.view.replace(edit, replace_region, '\n\n' + content) 696 | 697 | def _show_tooltip(self, sections): 698 | cursor = self.view.sel()[0].begin() 699 | line = self.view.line(cursor) 700 | 701 | line_content = self.view.substr(line) 702 | 703 | if line_content.startswith('-'): 704 | task = None 705 | for s in sections: 706 | task = s.find_by_line(line_content) 707 | if task is not None: 708 | break 709 | 710 | if task: 711 | content = '' 712 | categories = task.categories() 713 | max_len = max([len(c) for c in categories]) 714 | data = [] 715 | for cat in categories: 716 | data.append(( 717 | cat, 718 | task.scheduled_start_date(cat).date(), 719 | task.scheduled_end_date(cat).date() 720 | )) 721 | 722 | data = sorted(data, key=itemgetter(1)) 723 | 724 | for d in data: 725 | if d[1] == d[2]: 726 | content += '{:.>{}}: {}\n\n'.format( 727 | d[0], 728 | max_len, 729 | d[1]) 730 | else: 731 | content += '{:.>{}}: {} - {}\n\n'.format( 732 | d[0], 733 | max_len, 734 | d[1], 735 | d[2]) 736 | mdpopups.show_popup(self.view, content) 737 | 738 | def run(self, edit): 739 | 740 | self.errors = [] 741 | self.myrandomseed = 4567 742 | conf = sublime.load_settings('ProjectPlanner.sublime-settings') 743 | self.show_quarters = conf.get('show_quarters_on_graphs') 744 | 745 | content=self.view.substr(sublime.Region(0, self.view.size())) 746 | sections = self._extract_sections(content) 747 | 748 | statistics = self._compute_statistics(sections) 749 | self._estimate_missing_data(sections, statistics) 750 | self._compute_schedule(sections, statistics) 751 | 752 | self._mark_date_completed(sections, edit) 753 | self._update_section_timings(sections, edit, statistics) 754 | self._update_upcoming_tasks(sections, edit, statistics) 755 | self._update_planned_effort(sections, edit, statistics) 756 | self._draw_weekly_schedule(sections, edit, statistics) 757 | self._draw_section_schedule(sections, edit, statistics) 758 | self._update_timestamp_and_errors(edit) 759 | 760 | self._fold_links() 761 | 762 | self._show_tooltip(sections) 763 | --------------------------------------------------------------------------------