import things import argparse import re import random from datetime import datetime, timedelta with open('./emojis.txt', 'r') as emoji_file: EMOJIS = [l.strip() for l in emoji_file.readlines()] def mine_tags(tags, place): if place is None or not 'tags' in place: return tags tags.extend(place['tags']) def has_tag(task, tag): area = things.areas(uuid=task['area']) if 'area' in task else None project = things.projects(uuid=task['project']) if 'project' in task else None heading_project = things.projects(uuid=things.get(uuid=task['heading'])['project']) if 'heading' in task else None project_area = things.areas(uuid=project['area']) if project is not None and 'area' in project else None tags = [] mine_tags(tags, task) mine_tags(tags, area) mine_tags(tags, heading_project) mine_tags(tags, project) mine_tags(tags, project_area) return tag in tags def tasks_to_heirarchy(top_level_comment, tasks): heir = {} for task in tasks: task_leaf = (task, {}) if 'project' not in task and 'heading' not in task: heir[task['uuid']] = task_leaf elif 'heading' in task: h_uuid = task['heading'] head = things.get(h_uuid) p_uuid = head['project'] # This must exist proj = things.get(p_uuid) proj_title, proj_sublevel = heir[p_uuid] if p_uuid in heir else (proj, {}) head_title, head_sublevel = proj_sublevel[h_uuid] if h_uuid in proj_sublevel else (head, {}) head_sublevel[task['uuid']] = task_leaf proj_sublevel[head['uuid']] = (head_title, head_sublevel) heir[p_uuid] = (proj_title, proj_sublevel) else: # The project must not be None here p_uuid = task['project'] proj = things.get(p_uuid) proj_title, proj_sublevel = heir[p_uuid] if p_uuid in heir else (proj, {}) proj_sublevel[task['uuid']] = task_leaf heir[p_uuid] = (proj_title, proj_sublevel) return (top_level_comment, heir) def parse_note(note): note_block_pattern = r"```report\n([\s\S]*?)\n?```" return ' '.join(re.findall(note_block_pattern, note)) def make_recursive_formatter(formatter): def recursive_format(structure, depth): (node, branches) = structure notes = parse_note(node['notes']) node_str = formatter.node(node, notes) if len(branches) == 1: item = list(branches.values())[0] node_str += formatter.single(recursive_format(item, depth)) elif len(branches) > 1: new_depth = depth + 2 bullets = [formatter.bullet(f"{recursive_format(item, new_depth)}", new_depth) for item in branches.values()] node_str += ''.join(map(lambda b: f'\n{b}', bullets)) return node_str return recursive_format class TodayFormatter: def node(self, node, notes): return f"{node['title']}{'' if notes == '' else f' {notes}'}" def single(self, subtext): return f" > {subtext}" def bullet(self, subtext, depth): space = ''.join([' '] * depth) return f"{space}- {subtext}" class LogbookFormatter: def node(self, node, notes): node_text = f"{node['title']}{'' if notes == '' or node['status'] == 'canceled' else f' {notes}'}" if node['status'] == 'canceled': node_text = f"~{node_text}~" return node_text def single(self, subtext): return f" > {subtext}" def bullet(self, subtext, depth): space = ''.join([' '] * depth) return f"{space}- {subtext}" # Note: the characters on the RHS are all "lookalikes". The do not == the left side VOWEL_MAP = { 'a': 'а', 'e': 'e', 'i': 'і', 'o': 'о', 'u': 'ս', } def sanitize_name(name): for normal_ch, special_ch in VOWEL_MAP.items(): name = name.replace(normal_ch, special_ch) return name def sanitize_string(s, mapping): for original_str, new_str in mapping.items(): s = s.replace(original_str, new_str) return s def sanitize_mentions(task): if 'tags' not in task: return people_tags = { t[1:]: sanitize_name(t[1:]) for t in task['tags'] if t[0] == '@' } # For now just sanitize title and notes (headings might also make sense?) task['title'] = sanitize_string(task['title'], people_tags) task['notes'] = sanitize_string(task['notes'], people_tags) def same_date(d1, d2): day1 = str(d1).split()[0] day2 = str(d2).split()[0] return day1 == day2 def generate_signoff_message(target_tag): format_tasks = make_recursive_formatter(LogbookFormatter()) import datetime today = datetime.datetime.today() reportable = list(filter(lambda t: has_tag(t, target_tag) and same_date(today, t['stop_date']) and t['status'] == 'completed', things.logbook())) for task in reportable: sanitize_mentions(task) structured_tasks = tasks_to_heirarchy({ 'title': 'Stopping now', 'notes': '', 'status': '' }, reportable) return format_tasks(structured_tasks, 0) def by_modified_timestamp(val): return datetime.fromisoformat(val['modified']) def generate_today_message(target_tag): format_tasks = make_recursive_formatter(TodayFormatter()) reportable = list(filter(lambda t: has_tag(t, target_tag), things.today())) for task in sorted(reportable, key=by_modified_timestamp): sanitize_mentions(task) top_level_comment = ' '.join(map(lambda e: f':{e}:', random.choices(EMOJIS, k=3))) structured_tasks = tasks_to_heirarchy({ 'title': top_level_comment, 'notes': '' }, reportable) return format_tasks(structured_tasks, 0) def generate_track_message(target_tag): t = datetime.today() - timedelta(days=14) projects = filter(lambda x: x['type'] == 'project', things.logbook()) recent_projects = filter(lambda x: datetime.fromisoformat(x['modified']) > t, projects) selected_projects = filter(lambda x: 'tags' in x and target_tag in x['tags'], recent_projects) return '\n'.join(map(lambda x: x['title'], selected_projects)) if __name__ == '__main__': parser = argparse.ArgumentParser(description="Generate iknow_vacation_remote style messages from Things 3 Tasks") parser.add_argument('tags', metavar='TAG', type=str, nargs="+", help="a word in the overall tag") parser.add_argument('--signoff', action=argparse.BooleanOptionalAction, help="Get tasks from the logbook and generate output for a signoff message") parser.add_argument('--track', action=argparse.BooleanOptionalAction, help="Get tasks from the logbook and generate output for a track meeting") args = parser.parse_args() target_tag = ' '.join(args.tags) if args.signoff: print(generate_signoff_message(target_tag)) elif args.track: print(generate_track_message(target_tag)) else: print(generate_today_message(target_tag))