Created
July 16, 2016 10:21
-
-
Save fredcallaway/ffdf31d6a51c881d9be6f3a1d6784c6a to your computer and use it in GitHub Desktop.
PastDue for PlainTasks
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python | |
# -*- coding: utf-8 -*- | |
import sublime, sublime_plugin | |
import os | |
import re | |
import webbrowser | |
import itertools | |
from datetime import datetime | |
from datetime import timedelta | |
import dateutil | |
platform = sublime.platform() | |
ST2 = int(sublime.version()) < 3000 | |
if ST2: | |
import locale | |
# io is not operable in ST2 on Linux, but in all other cases io is better | |
# https://github.com/SublimeTextIssues/Core/issues/254 | |
if ST2 and platform == 'linux': | |
import codecs as io | |
else: | |
import io | |
NT = platform == 'windows' | |
if NT: | |
import subprocess | |
class PlainTasksBase(sublime_plugin.TextCommand): | |
def run(self, edit, **kwargs): | |
settings = self.view.settings() | |
self.taskpaper_compatible = settings.get('taskpaper_compatible', False) | |
if self.taskpaper_compatible: | |
self.open_tasks_bullet = self.done_tasks_bullet = self.canc_tasks_bullet = '-' | |
self.before_date_space = '' | |
else: | |
self.open_tasks_bullet = settings.get('open_tasks_bullet', u'☐') | |
self.done_tasks_bullet = settings.get('done_tasks_bullet', u'✔') | |
self.canc_tasks_bullet = settings.get('cancelled_tasks_bullet', u'✘') | |
self.before_date_space = settings.get('before_date_space', ' ') | |
translate_tabs_to_spaces = settings.get('translate_tabs_to_spaces', False) | |
self.before_tasks_bullet_spaces = ' ' * settings.get('before_tasks_bullet_margin', 1) if not self.taskpaper_compatible and translate_tabs_to_spaces else '\t' | |
self.tasks_bullet_space = settings.get('tasks_bullet_space', ' ' if self.taskpaper_compatible or translate_tabs_to_spaces else '\t') | |
self.date_format = settings.get('date_format', '(%y-%m-%d %H:%M)') | |
if settings.get('done_tag', True) or self.taskpaper_compatible: | |
self.done_tag = "@done" | |
self.canc_tag = "@cancelled" | |
else: | |
self.done_tag = "" | |
self.canc_tag = "" | |
self.project_postfix = settings.get('project_tag', True) | |
self.archive_name = settings.get('archive_name', 'Archive:') | |
# org-mode style archive stuff | |
self.archive_org_default_filemask = u'{dir}{sep}{base}_archive{ext}' | |
self.archive_org_filemask = settings.get('archive_org_filemask', self.archive_org_default_filemask) | |
if ST2: | |
self.sys_enc = locale.getpreferredencoding() | |
self.runCommand(edit, **kwargs) | |
if self.view.settings().get('highlight_past_due', False): | |
highlight_past_due(self.view) | |
class PlainTasksNewCommand(PlainTasksBase): | |
def runCommand(self, edit): | |
# list for ST3 support; | |
# reversed because with multiple selections regions would be messed up after first iteration | |
regions = itertools.chain(*(reversed(self.view.lines(region)) for region in reversed(list(self.view.sel())))) | |
header_to_task = self.view.settings().get('header_to_task', False) | |
# ST3 (3080) moves sel when call view.replace only by delta between original and | |
# new regions, so if sel is not in eol and we replace line with two lines, | |
# then cursor won’t be on next line as it should | |
sels = self.view.sel() | |
eol = None | |
for i, line in enumerate(regions): | |
line_contents = self.view.substr(line).rstrip() | |
not_empty_line = re.match('^(\s*)(\S.*)$', self.view.substr(line)) | |
empty_line = re.match('^(\s+)$', self.view.substr(line)) | |
current_scope = self.view.scope_name(line.a) | |
eol = line.b # need for ST3 when new content has line break | |
if 'item' in current_scope: | |
grps = not_empty_line.groups() | |
line_contents = self.view.substr(line) + '\n' + grps[0] + self.open_tasks_bullet + self.tasks_bullet_space | |
elif 'header' in current_scope and line_contents and not header_to_task: | |
grps = not_empty_line.groups() | |
line_contents = self.view.substr(line) + '\n' + grps[0] + self.before_tasks_bullet_spaces + self.open_tasks_bullet + self.tasks_bullet_space | |
elif 'separator' in current_scope: | |
grps = not_empty_line.groups() | |
line_contents = self.view.substr(line) + '\n' + grps[0] + self.before_tasks_bullet_spaces + self.open_tasks_bullet + self.tasks_bullet_space | |
elif not ('header' and 'separator') in current_scope or header_to_task: | |
eol = None | |
if not_empty_line: | |
grps = not_empty_line.groups() | |
line_contents = (grps[0] if len(grps[0]) > 0 else self.before_tasks_bullet_spaces) + self.open_tasks_bullet + self.tasks_bullet_space + grps[1] | |
elif empty_line: # only whitespaces | |
grps = empty_line.groups() | |
line_contents = grps[0] + self.open_tasks_bullet + self.tasks_bullet_space | |
else: # completely empty, no whitespaces | |
line_contents = self.before_tasks_bullet_spaces + self.open_tasks_bullet + self.tasks_bullet_space | |
else: | |
print('oops, need to improve PlainTasksNewCommand') | |
if eol: | |
# move cursor to eol of original line, workaround for ST3 | |
sels.subtract(sels[~i]) | |
sels.add(sublime.Region(eol, eol)) | |
self.view.replace(edit, line, line_contents) | |
# convert each selection to single cursor, ready to type | |
new_selections = [] | |
for sel in list(self.view.sel()): | |
eol = self.view.line(sel).b | |
new_selections.append(sublime.Region(eol, eol)) | |
self.view.sel().clear() | |
for sel in new_selections: | |
self.view.sel().add(sel) | |
PlainTasksStatsStatus.set_stats(self.view) | |
class PlainTasksNewWithDateCommand(PlainTasksBase): | |
def runCommand(self, edit): | |
self.view.run_command('plain_tasks_new') | |
sels = list(self.view.sel()) | |
suffix = ' @created%s' % datetime.now().strftime(self.date_format) | |
for s in reversed(sels): | |
self.view.insert(edit, s.b, suffix) | |
self.view.sel().clear() | |
offset = len(suffix) | |
for i, sel in enumerate(sels): | |
self.view.sel().add(sublime.Region(sel.a + i*offset, sel.b + i*offset)) | |
class PlainTasksCompleteCommand(PlainTasksBase): | |
def runCommand(self, edit): | |
original = [r for r in self.view.sel()] | |
try: | |
done_line_end = ' %s%s%s' % (self.done_tag, self.before_date_space, datetime.now().strftime(self.date_format).decode(self.sys_enc)) | |
except: | |
done_line_end = ' %s%s%s' % (self.done_tag, self.before_date_space, datetime.now().strftime(self.date_format)) | |
done_line_end = done_line_end.replace(' ', ' ').rstrip() | |
offset = len(done_line_end) | |
rom = r'^(\s*)(\[\s\]|.)(\s*.*)$' | |
rdm = r''' | |
(?x)^(\s*)(\[x\]|.) # 0,1 indent & bullet | |
(\s*[^\b]*?(?:[^\@]|(?<!\s)\@|\@(?=\s))*?\s*) # 2 very task | |
(?= | |
((?:\s@done|@project|$).*) # 3 ending either w/ done or w/o it & no date | |
| # or | |
(?:(\([^()]*\))\s*([^@]*|@project.*))?$ # 4 date & possible project tag after | |
) | |
''' # rcm is the same, except bullet & ending | |
rcm = r'^(\s*)(\[\-\]|.)(\s*[^\b]*?(?:[^\@]|(?<!\s)\@|\@(?=\s))*?\s*)(?=((?:\s@cancelled|@project|$).*)|(?:(\([^()]*\))\s*([^@]*|@project.*))?$)' | |
started = r'^\s*[^\b]*?\s*@started(\([\d\w,\.:\-\/ @]*\)).*$' | |
toggle = r'@toggle(\([\d\w,\.:\-\/ @]*\))' | |
regions = itertools.chain(*(reversed(self.view.lines(region)) for region in reversed(list(self.view.sel())))) | |
for line in regions: | |
line_contents = self.view.substr(line) | |
open_matches = re.match(rom, line_contents, re.U) | |
done_matches = re.match(rdm, line_contents, re.U) | |
canc_matches = re.match(rcm, line_contents, re.U) | |
started_matches = re.match(started, line_contents, re.U) | |
toggle_matches = re.findall(toggle, line_contents, re.U) | |
current_scope = self.view.scope_name(line.a) | |
if 'pending' in current_scope: | |
grps = open_matches.groups() | |
eol = self.view.insert(edit, line.end(), done_line_end) | |
replacement = u'%s%s%s' % (grps[0], self.done_tasks_bullet, grps[2]) | |
self.view.replace(edit, line, replacement) | |
if started_matches: | |
eol -= len(grps[1]) - len(self.done_tasks_bullet) | |
self.calc_end_start_time(self, edit, line, started_matches.group(1), toggle_matches, done_line_end, eol) | |
elif 'header' in current_scope: | |
eol = self.view.insert(edit, line.end(), done_line_end) | |
if started_matches: | |
self.calc_end_start_time(self, edit, line, started_matches.group(1), toggle_matches, done_line_end, eol) | |
indent = re.match('^(\s*)\S', line_contents, re.U) | |
self.view.insert(edit, line.begin() + len(indent.group(1)), '%s ' % self.done_tasks_bullet) | |
elif 'completed' in current_scope: | |
grps = done_matches.groups() | |
parentheses = self.check_parentheses(self.date_format, grps[4] or '') | |
replacement = u'%s%s%s%s' % (grps[0], self.open_tasks_bullet, grps[2], parentheses) | |
self.view.replace(edit, line, replacement.rstrip()) | |
offset = -offset | |
elif 'cancelled' in current_scope: | |
grps = canc_matches.groups() | |
self.view.insert(edit, line.end(), done_line_end) | |
parentheses = self.check_parentheses(self.date_format, grps[4] or '') | |
replacement = u'%s%s%s%s' % (grps[0], self.done_tasks_bullet, grps[2], parentheses) | |
self.view.replace(edit, line, replacement.rstrip()) | |
offset = -offset | |
self.view.sel().clear() | |
for ind, pt in enumerate(original): | |
ofs = ind * offset | |
new_pt = sublime.Region(pt.a + ofs, pt.b + ofs) | |
self.view.sel().add(new_pt) | |
PlainTasksStatsStatus.set_stats(self.view) | |
@staticmethod | |
def calc_end_start_time(self, edit, line, started_matches, toggle_matches, done_line_end, eol, tag='lasted'): | |
start = datetime.strptime(started_matches, self.date_format) | |
end = datetime.strptime(done_line_end.replace('@done', '').replace('@cancelled', '').strip(), self.date_format) | |
toggle_times = [datetime.strptime(toggle, self.date_format) for toggle in toggle_matches] | |
all_times = [start] + toggle_times + [end] | |
pairs = zip(all_times[::2], all_times[1::2]) | |
deltas = [pair[1] - pair[0] for pair in pairs] | |
delta = sum(deltas, timedelta()) | |
if self.view.settings().get('decimal_minutes', False): | |
days = delta.days | |
delta = u'%s%s%s%.2f' % (days or '', ' day, ' if days == 1 else '', ' days, ' if days > 1 else '', delta.seconds/3600.0) | |
else: | |
delta = str(delta) | |
if delta[~6:] == '0:00:00': # strip meaningless time | |
delta = delta[:~6] | |
elif delta[~2:] == ':00': # strip meaningless seconds | |
delta = delta[:~2] | |
tag = ' @%s(%s)' % (tag, delta.rstrip(', ') if delta else ('a bit' if '%H' in self.date_format else 'less than day')) | |
self.view.insert(edit, line.end() + eol, tag) | |
@staticmethod | |
def check_parentheses(date_format, regex_group, is_date=False): | |
if is_date: | |
try: | |
parentheses = regex_group if datetime.strptime(regex_group.strip(), date_format) else '' | |
except ValueError: | |
parentheses = '' | |
else: | |
try: | |
parentheses = '' if datetime.strptime(regex_group.strip(), date_format) else regex_group | |
except ValueError: | |
parentheses = regex_group | |
return parentheses | |
class PlainTasksCancelCommand(PlainTasksBase): | |
def runCommand(self, edit): | |
print('run cancel') | |
original = [r for r in self.view.sel()] | |
try: | |
canc_line_end = ' %s%s%s' % (self.canc_tag, self.before_date_space, datetime.now().strftime(self.date_format).decode(self.sys_enc)) | |
except: | |
canc_line_end = ' %s%s%s' % (self.canc_tag, self.before_date_space, datetime.now().strftime(self.date_format)) | |
canc_line_end = canc_line_end.replace(' ', ' ').rstrip() | |
offset = len(canc_line_end) | |
rom = r'^(\s*)(\[\s\]|.)(\s*.*)$' | |
rdm = r'^(\s*)(\[x\]|.)(\s*[^\b]*?(?:[^\@]|(?<!\s)\@|\@(?=\s))*?\s*)(?=((?:\s@done|@project|$).*)|(?:(\([^()]*\))\s*([^@]*|@project.*))?$)' | |
rcm = r'^(\s*)(\[\-\]|.)(\s*[^\b]*?(?:[^\@]|(?<!\s)\@|\@(?=\s))*?\s*)(?=((?:\s@cancelled|@project|$).*)|(?:(\([^()]*\))\s*([^@]*|@project.*))?$)' | |
started = '^\s*[^\b]*?\s*@started(\([\d\w,\.:\-\/ @]*\)).*$' | |
toggle = r'@toggle(\([\d\w,\.:\-\/ @]*\))' | |
regions = itertools.chain(*(reversed(self.view.lines(region)) for region in reversed(list(self.view.sel())))) | |
for line in regions: | |
line_contents = self.view.substr(line) | |
open_matches = re.match(rom, line_contents, re.U) | |
done_matches = re.match(rdm, line_contents, re.U) | |
canc_matches = re.match(rcm, line_contents, re.U) | |
started_matches = re.match(started, line_contents, re.U) | |
toggle_matches = re.findall(toggle, line_contents, re.U) | |
current_scope = self.view.scope_name(line.a) | |
if 'pending' in current_scope: | |
grps = open_matches.groups() | |
eol = self.view.insert(edit, line.end(), canc_line_end) | |
replacement = u'%s%s%s' % (grps[0], self.canc_tasks_bullet, grps[2]) | |
self.view.replace(edit, line, replacement) | |
if started_matches: | |
eol -= len(grps[1]) - len(self.canc_tasks_bullet) | |
PlainTasksCompleteCommand.calc_end_start_time(self, edit, line, started_matches.group(1), toggle_matches, canc_line_end, eol, tag='wasted') | |
elif 'header' in current_scope: | |
eol = self.view.insert(edit, line.end(), canc_line_end) | |
if started_matches: | |
PlainTasksCompleteCommand.calc_end_start_time(self, edit, line, started_matches.group(1), toggle_matches, canc_line_end, eol, tag='wasted') | |
indent = re.match('^(\s*)\S', line_contents, re.U) | |
self.view.insert(edit, line.begin() + len(indent.group(1)), '%s ' % self.canc_tasks_bullet) | |
elif 'completed' in current_scope: | |
sublime.status_message('You cannot cancel what have been done, can you?') | |
# grps = done_matches.groups() | |
# parentheses = PlainTasksCompleteCommand.check_parentheses(self.date_format, grps[4] or '') | |
# replacement = u'%s%s%s%s' % (grps[0], self.canc_tasks_bullet, grps[2], parentheses) | |
# self.view.replace(edit, line, replacement.rstrip()) | |
# offset = -offset | |
elif 'cancelled' in current_scope: | |
grps = canc_matches.groups() | |
parentheses = PlainTasksCompleteCommand.check_parentheses(self.date_format, grps[4] or '') | |
replacement = u'%s%s%s%s' % (grps[0], self.open_tasks_bullet, grps[2], parentheses) | |
self.view.replace(edit, line, replacement.rstrip()) | |
offset = -offset | |
self.view.sel().clear() | |
for ind, pt in enumerate(original): | |
ofs = ind * offset | |
new_pt = sublime.Region(pt.a + ofs, pt.b + ofs) | |
self.view.sel().add(new_pt) | |
PlainTasksStatsStatus.set_stats(self.view) | |
class PlainTasksArchiveCommand(PlainTasksBase): | |
def runCommand(self, edit, partial=False): | |
rds = 'meta.item.todo.completed' | |
rcs = 'meta.item.todo.cancelled' | |
# finding archive section | |
archive_pos = self.view.find(self.archive_name, 0, sublime.LITERAL) | |
if partial: | |
all_tasks = self.get_archivable_tasks_within_selections() | |
else: | |
all_tasks = self.get_all_archivable_tasks(archive_pos, rds, rcs) | |
if not all_tasks: | |
sublime.status_message('Nothing to archive') | |
else: | |
if archive_pos and archive_pos.a > 0: | |
line = self.view.full_line(archive_pos).end() | |
else: | |
create_archive = u'\n\n___________________\n' + self.archive_name + '\n' | |
self.view.insert(edit, self.view.size(), create_archive) | |
line = self.view.size() | |
# because tmLanguage need \n to make background full width of window | |
# multiline headers are possible, thus we have to split em to be sure that | |
# one header == one line | |
projects = itertools.chain(*[self.view.lines(r) for r in self.view.find_by_selector('keyword.control.header.todo')]) | |
projects = sorted(list(projects) + | |
self.view.find_by_selector('meta.punctuation.separator.todo')) | |
# adding tasks to archive section | |
for task in all_tasks: | |
match_task = re.match('^\s*(\[[x-]\]|.)(\s+.*$)', self.view.substr(task), re.U) | |
current_scope = self.view.scope_name(task.a) | |
if rds in current_scope or rcs in current_scope: | |
pr = self.get_task_project(task, projects) | |
if self.project_postfix: | |
eol = (self.before_tasks_bullet_spaces + self.view.substr(task).lstrip() + | |
(' @project(' if pr else '') + pr + (')' if pr else '') + | |
'\n') | |
else: | |
eol = (self.before_tasks_bullet_spaces + | |
match_task.group(1) + # bullet | |
(self.tasks_bullet_space if pr else '') + pr + (':' if pr else '') + | |
match_task.group(2) + # very task | |
'\n') | |
else: | |
eol = self.before_tasks_bullet_spaces * 2 + self.view.substr(task).lstrip() + '\n' | |
line += self.view.insert(edit, line, eol) | |
# remove moved tasks (starting from the last one otherwise it screw up regions after the first delete) | |
for task in reversed(all_tasks): | |
self.view.erase(edit, self.view.full_line(task)) | |
self.view.run_command('plain_tasks_sort_by_date') | |
def get_task_project(self, task, projects): | |
index = -1 | |
for ind, pr in enumerate(projects): | |
if task < pr: | |
if ind > 0: | |
index = ind-1 | |
break | |
#if there is no projects for task - return empty string | |
if index == -1: | |
return '' | |
prog = re.compile('^\n*(\s*)(.+):(?=\s|$)\s*(\@[^\s]+(\(.*?\))?\s*)*') | |
hierarhProject = '' | |
if index >= 0: | |
depth = re.match(r"\s*", self.view.substr(self.view.line(task))).group() | |
while index >= 0: | |
strProject = self.view.substr(projects[index]) | |
if prog.match(strProject): | |
spaces = prog.match(strProject).group(1) | |
if len(spaces) < len(depth): | |
hierarhProject = prog.match(strProject).group(2) + ((" / " + hierarhProject) if hierarhProject else '') | |
depth = spaces | |
if len(depth) == 0: | |
break | |
else: | |
sep = re.compile('(^\s*)---.{3,5}---+$') | |
spaces = sep.match(strProject).group(1) | |
if len(spaces) < len(depth): | |
depth = spaces | |
if len(depth) == 0: | |
break | |
index -= 1 | |
if not hierarhProject: | |
return '' | |
else: | |
return hierarhProject | |
def get_task_note(self, task, tasks): | |
note_line = task.end() + 1 | |
while self.view.scope_name(note_line) == 'text.todo notes.todo ': | |
note = self.view.line(note_line) | |
if note not in tasks: | |
tasks.append(note) | |
note_line = self.view.line(note_line).end() + 1 | |
def get_all_archivable_tasks(self, archive_pos, rds, rcs): | |
done_tasks = [i for i in self.view.find_by_selector(rds) if i.a < (archive_pos.a if archive_pos and archive_pos.a > 0 else self.view.size())] | |
for i in done_tasks: | |
self.get_task_note(i, done_tasks) | |
canc_tasks = [i for i in self.view.find_by_selector(rcs) if i.a < (archive_pos.a if archive_pos and archive_pos.a > 0 else self.view.size())] | |
for i in canc_tasks: | |
self.get_task_note(i, canc_tasks) | |
all_tasks = done_tasks + canc_tasks | |
all_tasks.sort() | |
return all_tasks | |
def get_archivable_tasks_within_selections(self): | |
all_tasks = [] | |
for region in self.view.sel(): | |
for l in self.view.lines(region): | |
line = self.view.line(l) | |
if ('completed' in self.view.scope_name(line.a)) or ('cancelled' in self.view.scope_name(line.a)): | |
all_tasks.append(line) | |
self.get_task_note(line, all_tasks) | |
return all_tasks | |
class PlainTasksNewTaskDocCommand(sublime_plugin.WindowCommand): | |
def run(self): | |
view = self.window.new_file() | |
view.settings().add_on_change('color_scheme', lambda: self.set_proper_scheme(view)) | |
view.set_syntax_file('Packages/PlainTasks/PlainTasks.tmLanguage') | |
def set_proper_scheme(self, view): | |
if view.id() != sublime.active_window().active_view().id(): | |
return | |
pts = sublime.load_settings('PlainTasks.sublime-settings') | |
if view.settings().get('color_scheme') == pts.get('color_scheme'): | |
return | |
# Since we cannot create file with syntax, there is moment when view has no settings, | |
# but it is activated, so some plugins (e.g. Color Highlighter) set wrong color scheme | |
view.settings().set('color_scheme', pts.get('color_scheme')) | |
class PlainTasksOpenUrlCommand(sublime_plugin.TextCommand): | |
#It is horrible regex but it works perfectly | |
URL_REGEX = r"""(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\)) | |
+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))""" | |
def run(self, edit): | |
s = self.view.sel()[0] | |
start, end = s.a, s.b | |
if 'url' in self.view.scope_name(start): | |
while self.view.substr(start) != '<': start -= 1 | |
while self.view.substr(end) != '>': end += 1 | |
rgn = sublime.Region(start + 1, end) | |
# optional select URL | |
self.view.sel().add(rgn) | |
url = self.view.substr(rgn) | |
if NT and all([not ST2, ':' in url]): | |
# webbrowser uses os.startfile() under the hood, and it is not reliable in py3; | |
# thus call start command for url with scheme (eg skype:nick) and full path (eg c:\b) | |
subprocess.Popen(['start', url], shell=True) | |
else: | |
webbrowser.open_new_tab(url) | |
else: | |
self.search_bare_weblink_and_open(start, end) | |
def search_bare_weblink_and_open(self, start, end): | |
# expand selection to nearest stopSymbols | |
view_size = self.view.size() | |
stopSymbols = ['\t', ' ', '\"', '\'', '>', '<', ','] | |
# move the selection back to the start of the url | |
while (start > 0 | |
and not self.view.substr(start - 1) in stopSymbols | |
and self.view.classify(start) & sublime.CLASS_LINE_START == 0): | |
start -= 1 | |
# move end of selection forward to the end of the url | |
while (end < view_size | |
and not self.view.substr(end) in stopSymbols | |
and self.view.classify(end) & sublime.CLASS_LINE_END == 0): | |
end += 1 | |
# grab the URL | |
url = self.view.substr(sublime.Region(start, end)) | |
# optional select URL | |
self.view.sel().add(sublime.Region(start, end)) | |
exp = re.search(self.URL_REGEX, url, re.X) | |
if exp and exp.group(0): | |
strUrl = exp.group(0) | |
if strUrl.find("://") == -1: | |
strUrl = "http://" + strUrl | |
webbrowser.open_new_tab(strUrl) | |
else: | |
sublime.status_message("Looks like there is nothing to open") | |
class PlainTasksOpenLinkCommand(sublime_plugin.TextCommand): | |
LINK_PATTERN = re.compile( # simple ./path/ | |
r'''(?ixu)(?:^|[ \t])\.[\\/] | |
(?P<fn> | |
(?:[a-z]\:[\\/])? # special case for Windows full path | |
(?:[^\\/:">]+[\\/]?)+) # the very path (single filename/relative/full) | |
(?=[\\/:">]) # stop matching path | |
# options: | |
(>(?P<sym>\w+))?(\:(?P<line>\d+))?(\:(?P<col>\d+))?(\"(?P<text>[^\n]*)\")? | |
''') | |
MD_LINK = re.compile( # markdown [](path) | |
r'''(?ixu)\][ \t]*\(\<?(?:file\:///?)? | |
(?P<fn>.*?((\\\))?.*?)*) | |
(?:\>?[ \t]* | |
\"((\:(?P<line>\d+))?(\:(?P<col>\d+))?|(\>(?P<sym>\w+))?|(?P<text>[^\n]*)) | |
\")? | |
\) | |
''') | |
WIKI_LINK = re.compile( # ORGMODE, NV, and all similar formats [[link][opt-desc]] | |
r'''(?ixu)\[\[(?:file(?:\+(?:sys|emacs))?\:)?(?:\.[\\/])? | |
(?P<fn>.*?((\\\])?.*?)*) | |
(?# options for orgmode link [[path::option]]) | |
(?:\:\:(((?P<line>\d+))?(\:(?P<col>\d+))?|(\*(?P<sym>\w+))?|(?P<text>.*?((\\\])?.*?)*)))? | |
\](?:\[(.*?)\])? | |
\] | |
(?# options for NV [[path]] "option" — NV not support it, but PT should support so it wont break NV) | |
(?:[ \t]* | |
\"((\:(?P<linen>\d+))?(\:(?P<coln>\d+))?|(\>(?P<symn>\w+))?|(?P<textn>[^\n]*)) | |
\")? | |
''') | |
def _format_res(self, res): | |
if res[3] == 'f': | |
return [res[0], "line: %d column: %d" % (int(res[1]), int(res[2]))] | |
elif res[3] == 'd': | |
return [res[0], 'Add folder to project' if not ST2 else 'Folders are supported only in Sublime 3'] | |
def _on_panel_selection(self, selection): | |
if selection >= 0: | |
res = self._current_res[selection] | |
win = sublime.active_window() | |
if ST2 and res[3] == "d": | |
return sublime.status_message('Folders are supported only in Sublime 3') | |
elif res[3] == "d": | |
data = win.project_data() | |
if not data: | |
data = {} | |
if "folders" not in data: | |
data["folders"] = [] | |
data["folders"].append({'follow_symlinks': True, | |
'path': res[0]}) | |
win.set_project_data(data) | |
else: | |
self.opened_file = win.open_file('%s:%s:%s' % res[:3], | |
sublime.ENCODED_POSITION) | |
def show_panel_or_open(self, fn, sym, line, col, text): | |
win = sublime.active_window() | |
self._current_res = list() | |
if sym: | |
for name, _, pos in win.lookup_symbol_in_index(sym): | |
if name.endswith(fn): | |
line, col = pos | |
self._current_res.append((name, line, col, "f")) | |
else: | |
fn = fn.replace('/', os.sep) | |
all_folders = win.folders() + [os.path.dirname(v.file_name()) for v in win.views() if v.file_name()] | |
for folder in set(all_folders): | |
for root, _, _ in os.walk(folder): | |
name = os.path.abspath(os.path.join(root, fn)) | |
if os.path.isfile(name): | |
self._current_res.append((name, line or 0, col or 0, "f")) | |
if os.path.isdir(name): | |
self._current_res.append((name, 0, 0, "d")) | |
if os.path.isfile(fn): # check for full path | |
self._current_res.append((fn, line or 0, col or 0, "f")) | |
elif os.path.isdir(fn): | |
self._current_res.append((fn, 0, 0, "d")) | |
self._current_res = list(set(self._current_res)) | |
if not self._current_res: | |
sublime.error_message('File was not found\n\n\t%s' % fn) | |
if len(self._current_res) == 1: | |
self._on_panel_selection(0) | |
else: | |
entries = [self._format_res(res) for res in self._current_res] | |
win.show_quick_panel(entries, self._on_panel_selection) | |
def run(self, edit, fn=None): | |
point = self.view.sel()[0].begin() | |
line = self.view.substr(self.view.line(point)) | |
match_link = self.LINK_PATTERN.search(line) | |
match_md = self.MD_LINK.search(line) | |
match_wiki = self.WIKI_LINK.search(line) | |
if match_link: | |
fn, sym, line, col, text = match_link.group('fn', 'sym', 'line', 'col', 'text') | |
elif match_md: | |
fn, sym, line, col, text = match_md.group('fn', 'sym', 'line', 'col', 'text') | |
# unescape some chars | |
fn = (fn.replace('\\(', '(').replace('\\)', ')')) | |
elif match_wiki: | |
fn = match_wiki.group('fn') | |
sym = match_wiki.group('sym') or match_wiki.group('symn') | |
line = match_wiki.group('line') or match_wiki.group('linen') | |
col = match_wiki.group('col') or match_wiki.group('coln') | |
text = match_wiki.group('text') or match_wiki.group('textn') | |
# unescape some chars | |
fn = (fn.replace('\\[', '[').replace('\\]', ']')) | |
if text: | |
text = (text.replace('\\[', '[').replace('\\]', ']')) | |
if fn: | |
self.show_panel_or_open(fn, sym, line, col, text) | |
if text: | |
sublime.set_timeout(lambda: self.find_text(self.opened_file, text, line), 300) | |
else: | |
sublime.status_message('Line does not contain a valid link to file') | |
def find_text(self, view, text, line): | |
result = view.find(text, view.sel()[0].a if line else 0, sublime.LITERAL) | |
view.sel().clear() | |
view.sel().add(result.a) | |
view.set_viewport_position(view.text_to_layout(view.size()), False) | |
view.show_at_center(result) | |
class PlainTasksSortByDate(PlainTasksBase): | |
def runCommand(self, edit): | |
archive_pos = self.view.find(self.archive_name, 0, sublime.LITERAL) | |
if archive_pos: | |
have_date = '(^\s*[^\n]*?\s\@(?:done|cancelled)\s*(\([\d\w,\.:\-\/ ]*\))[^\n]*$)' | |
tasks_prefixed_date = [] | |
tasks = self.view.find_all(have_date, archive_pos.b-1, "\\2\\1", tasks_prefixed_date) | |
notes = [] | |
for ind, task in enumerate(tasks): | |
note_line = task.end() + 1 | |
while self.view.scope_name(note_line) == 'text.todo notes.todo ': | |
note = self.view.line(note_line) | |
notes.append(note) | |
tasks_prefixed_date[ind] += '\n' + self.view.substr(note) | |
note_line = note.end() + 1 | |
to_remove = tasks+notes | |
to_remove.sort() | |
for i in reversed(to_remove): | |
self.view.erase(edit, self.view.full_line(i)) | |
tasks_prefixed_date.sort(reverse=self.view.settings().get('new_on_top', True)) | |
eol = archive_pos.end() | |
for a in tasks_prefixed_date: | |
eol += self.view.insert(edit, eol, '\n' + re.sub('^\([\d\w,\.:\-\/ ]*\)([^\b]*$)', '\\1', a)) | |
else: | |
sublime.status_message("Nothing to sort") | |
class PlainTaskInsertDate(PlainTasksBase): | |
def runCommand(self, edit): | |
for s in reversed(list(self.view.sel())): | |
self.view.insert(edit, s.b, datetime.now().strftime(self.date_format)) | |
class PlainTasksToggleHighlightPastDue(PlainTasksBase): | |
def runCommand(self, edit): | |
highlight_on = self.view.settings().get('highlight_past_due', False) | |
if highlight_on: | |
self.view.erase_regions('past_due') | |
self.view.erase_regions('due_soon') | |
self.view.erase_regions('misformatted') | |
self.view.settings().set('highlight_past_due', False) | |
else: | |
self.view.settings().set('highlight_past_due', True) | |
def highlight_past_due(view): | |
"""Highlights all due dates that are in the past.""" | |
# Find regions that are past due and due in next 24 hours. | |
past_due = [] | |
due_soon = [] | |
misformatted = [] | |
pattern = r'@due\(.*\)' | |
now = datetime.now() | |
for region in view.find_all(pattern): | |
date_string = view.substr(region)[6:-1] | |
try: | |
date = dateutil.parser.parse(date_string, yearfirst=True, dayfirst=False) | |
except: | |
misformatted.append(region) | |
else: | |
if now > date: | |
past_due.append(region) | |
else: | |
due_soon_threshold = view.settings().get('highlight_due_soon') * 60 * 60 | |
if due_soon_threshold: | |
time_left = (date - now).total_seconds() | |
if time_left < due_soon_threshold: | |
due_soon.append(region) | |
view.add_regions('past_due', past_due, 'variable', 'circle') | |
view.add_regions('due_soon', due_soon, 'variable', 'dot', sublime.DRAW_NO_FILL) | |
view.add_regions('misformatted', misformatted, 'variable', '', sublime.DRAW_NO_FILL | | |
sublime.DRAW_NO_OUTLINE | sublime.DRAW_STIPPLED_UNDERLINE) | |
#for word in words: | |
# if len(word) < 2 or word in word_set: | |
# continue | |
# word_set.add(word) | |
# regions = view.find_all(word, flag) | |
# view.add_regions('highlight_word_%d' % size, regions, 'invalid') | |
# size += 1 | |
#view.settings().set('highlight_size', size) | |
#view.settings().set('highlight_text', text) | |
class PlainTasksReplaceShortDate(PlainTasksBase): | |
def runCommand(self, edit): | |
self.date_format = self.date_format.strip('()') | |
now = datetime.now() | |
s = self.view.sel()[0] | |
start, end = s.a, s.b | |
while self.view.substr(start) != '(': | |
start -= 1 | |
while self.view.substr(end) != ')': | |
end += 1 | |
self.rgn = sublime.Region(start + 1, end) | |
matchstr = self.view.substr(self.rgn) | |
# print(matchstr) | |
if '+' in matchstr: | |
date = self.increase_date(matchstr, now) | |
else: | |
date = self.convert_date(matchstr, now) | |
self.view.replace(edit, self.rgn, date) | |
offset = start + len(date) + 2 | |
self.view.sel().clear() | |
self.view.sel().add(sublime.Region(offset, offset)) | |
def increase_date(self, matchstr, now): | |
# relative from date of creation if any | |
if '++' in matchstr: | |
line_content = self.view.substr(self.view.line(self.rgn)) | |
created = re.search(r'(?mxu)@created\(([\d\w,\.:\-\/ @]*)\)', line_content) | |
if created: | |
try: | |
now = datetime.strptime(created.group(1), self.date_format) | |
except ValueError as e: | |
return sublime.error_message('PlainTasks:\n\n FAILED date convertion: %s' % e) | |
match_obj = re.search(r'''(?mxu) | |
\s*\+\+?\s* | |
(?: | |
(?P<number>\d*(?![:.]))\s* | |
(?P<days>[Dd]?) | |
(?P<weeks>[Ww]?) | |
(?! \d*[:.]) | |
)? | |
\s* | |
(?: | |
(?P<hour>\d*) | |
[:.] | |
(?P<minute>\d*) | |
)?''', matchstr) | |
number = int(match_obj.group('number') or 0) | |
days = match_obj.group('days') | |
weeks = match_obj.group('weeks') | |
hour = int(match_obj.group('hour') or 0) | |
minute = int(match_obj.group('minute') or 0) | |
if not (number or hour or minute) or (not number and (days or weeks)): | |
# set 1 if number is ommited, i.e. | |
# @due(+) == @due(+1) == @due(+1d) | |
# @due(+w) == @due(+1w) | |
number = 1 | |
delta = now + timedelta(days=(number*7 if weeks else number), minutes=minute, hours=hour) | |
return delta.strftime(self.date_format) | |
def convert_date(self, matchstr, now): | |
match_obj = re.search(r'''(?mxu) | |
(?:\s* | |
(?P<yearORmonthORday>\d*(?!:)) | |
(?P<sep>[-\.])? | |
(?P<monthORday>\d*) | |
(?P=sep)? | |
(?P<day>\d*) | |
(?! \d*:)(?# e.g. '23:' == hour, but '1 23:' == day=1, hour=23) | |
)? | |
\s* | |
(?: | |
(?P<hour>\d*) | |
: | |
(?P<minute>\d*) | |
)?''', matchstr) | |
year = now.year | |
month = now.month | |
day = int(match_obj.group('day') or 0) | |
# print(day) | |
if day: | |
year = int(match_obj.group('yearORmonthORday')) | |
month = int(match_obj.group('monthORday')) | |
else: | |
day = int(match_obj.group('monthORday') or 0) | |
# print(day) | |
if day: | |
month = int(match_obj.group('yearORmonthORday')) | |
if month < now.month: | |
year += 1 | |
else: | |
day = int(match_obj.group('yearORmonthORday') or 0) | |
# print(day) | |
if 0 < day <= now.day: | |
# expect next month | |
month += 1 | |
if month == 13: | |
year += 1 | |
month = 1 | |
elif not day: # @due(0) == today | |
day = now.day | |
# else would be day>now, i.e. future day in current month | |
hour = match_obj.group('hour') or now.hour | |
minute = match_obj.group('minute') or now.minute | |
hour, minute = int(hour), int(minute) | |
if year < 100: | |
year += 2000 | |
# print(year, month, day, hour, minute) | |
try: | |
date = datetime(year, month, day, hour, minute, 0).strftime(self.date_format) | |
except ValueError as e: | |
return sublime.error_message('PlainTasks:\n\n' | |
'%s:\n year:\t%d\n month:\t%d\n day:\t%d\n HH:\t%d\n MM:\t%d\n' % | |
(e, year, month, day, hour, minute)) | |
else: | |
return date | |
class PlainTasksRemoveBold(sublime_plugin.TextCommand): | |
def run(self, edit): | |
for s in reversed(list(self.view.sel())): | |
a, b = s.begin(), s.end() | |
for r in sublime.Region(b + 2, b), sublime.Region(a - 2, a): | |
self.view.erase(edit, r) | |
class PlainTasksStatsStatus(sublime_plugin.EventListener): | |
def on_activated(self, view): | |
if not view.score_selector(0, "text.todo") > 0: | |
return | |
self.set_stats(view) | |
@staticmethod | |
def set_stats(view): | |
view.set_status('PlainTasks', PlainTasksStatsStatus.get_stats(view)) | |
@staticmethod | |
def get_stats(view): | |
msgf = view.settings().get('stats_format', '$n/$a done ($percent%) $progress Last task @done $last') | |
special_interest = re.findall(r'{{.*?}}', msgf) | |
for i in special_interest: | |
matches = view.find_all(i.strip('{}')) | |
pend, done, canc = [], [], [] | |
for t in matches: | |
# one task may contain same tag/word several times—we count amount of tasks, not tags | |
t = view.line(t).a | |
scope = view.scope_name(t) | |
if 'pending' in scope and t not in pend: | |
pend.append(t) | |
elif 'completed' in scope and t not in done: | |
done.append(t) | |
elif 'cancelled' in scope and t not in canc: | |
canc.append(t) | |
msgf = msgf.replace(i, '%d/%d/%d'%(len(pend), len(done), len(canc))) | |
ignore_archive = view.settings().get('stats_ignore_archive', False) | |
if ignore_archive: | |
archive_pos = view.find(view.settings().get('archive_name', 'Archive:'), 0, sublime.LITERAL) | |
pend = len([i for i in view.find_by_selector('meta.item.todo.pending') if i.a < (archive_pos.a if archive_pos and archive_pos.a > 0 else view.size())]) | |
done = len([i for i in view.find_by_selector('meta.item.todo.completed') if i.a < (archive_pos.a if archive_pos and archive_pos.a > 0 else view.size())]) | |
canc = len([i for i in view.find_by_selector('meta.item.todo.cancelled') if i.a < (archive_pos.a if archive_pos and archive_pos.a > 0 else view.size())]) | |
else: | |
pend = len(view.find_by_selector('meta.item.todo.pending')) | |
done = len(view.find_by_selector('meta.item.todo.completed')) | |
canc = len(view.find_by_selector('meta.item.todo.cancelled')) | |
allt = pend + done + canc | |
percent = ((done+canc)/float(allt))*100 if allt else 0 | |
factor = int(round(percent/10)) if percent<90 else int(percent/10) | |
barfull = view.settings().get('bar_full', u'■') | |
barempty = view.settings().get('bar_empty', u'□') | |
progress = '%s%s' % (barfull*factor, barempty*(10-factor)) if factor else '' | |
tasks_dates = [] | |
view.find_all('(^\s*[^\n]*?\s\@(?:done)\s*(\([\d\w,\.:\-\/ ]*\))[^\n]*$)', 0, "\\2", tasks_dates) | |
date_format = view.settings().get('date_format', '(%y-%m-%d %H:%M)') | |
tasks_dates = [PlainTasksCompleteCommand.check_parentheses(date_format, t, is_date=True) for t in tasks_dates] | |
tasks_dates.sort(reverse=True) | |
last = tasks_dates[0] if tasks_dates else '(UNKNOWN)' | |
msg = (msgf.replace('$o', str(pend)) | |
.replace('$d', str(done)) | |
.replace('$c', str(canc)) | |
.replace('$n', str(done+canc)) | |
.replace('$a', str(allt)) | |
.replace('$percent', str(int(percent))) | |
.replace('$progress', progress) | |
.replace('$last', last) | |
) | |
return msg | |
class PlainTasksCopyStats(sublime_plugin.TextCommand): | |
def is_enabled(self): | |
return self.view.score_selector(0, "text.todo") > 0 | |
def run(self, edit): | |
msg = self.view.get_status('PlainTasks') | |
replacements = self.view.settings().get('replace_stats_chars', []) | |
if replacements: | |
for o, r in replacements: | |
msg = msg.replace(o, r) | |
sublime.set_clipboard(msg) | |
class PlainTasksArchiveOrgCommand(PlainTasksBase): | |
def runCommand(self, edit): | |
# Archive the curent subtree to our archive file, not just completed tasks. | |
# For now, it's mapped to ctrl-shift-o or super-shift-o | |
# TODO: Mark any tasks found as complete, or maybe warn. | |
# Get our archive filename | |
archive_filename = self.__createArchiveFilename() | |
# Figure out our subtree | |
region = self.__findCurrentSubtree() | |
if region.empty(): | |
# How can we get here? | |
sublime.error_message("Error:\n\nCould not find a tree to archive.") | |
return | |
# Write our region or our archive file | |
success = self.__writeArchive(archive_filename, region) | |
# only erase our region if the write was successful | |
if success: | |
self.view.erase(edit,region) | |
return | |
def __writeArchive(self, filename, region): | |
# Write out the given region | |
sublime.status_message(u'Archiving tree to {0}'.format(filename)) | |
try: | |
# Have to use io.open because windows doesn't like writing | |
# utf8 to regular filehandles | |
with io.open(filename, 'a', encoding='utf8') as fh: | |
data = self.view.substr(region) | |
# Is there a way to read this in? | |
fh.write(u"--- ✄ -----------------------\n") | |
fh.write(u"Archived {0}:\n".format(datetime.now().strftime( | |
self.date_format))) | |
# And, finally, write our data | |
fh.write(u"{0}\n".format(data)) | |
return True | |
except Exception as e: | |
sublime.error_message(u"Error:\n\nUnable to append to {0}\n{1}".format( | |
filename, str(e))) | |
return False | |
def __createArchiveFilename(self): | |
# Create our archive filename, from the mask in our settings. | |
# Split filename int dir, base, and extension, then apply our mask | |
path_base, extension = os.path.splitext(self.view.file_name()) | |
dir = os.path.dirname(path_base) | |
base = os.path.basename(path_base) | |
sep = os.sep | |
# Now build our new filename | |
try: | |
# This could fail, if someone messed up the mask in the | |
# settings. So, if it did fail, use our default. | |
archive_filename = self.archive_org_filemask.format( | |
dir=dir, base=base, ext=extension, sep=sep) | |
except: | |
# Use our default mask | |
archive_filename = self.archive_org_default_filemask.format( | |
dir=dir, base=base, ext=extension, sep=sep) | |
# Display error, letting the user know | |
sublime.error_message(u"Error:\n\nInvalid filemask:{0}\nUsing default: {1}".format( | |
self.archive_org_filemask, self.archive_org_default_filemask)) | |
return archive_filename | |
def __findCurrentSubtree(self): | |
# Return the region that starts at the cursor, or starts at | |
# the beginning of the selection | |
line = self.view.line(self.view.sel()[0].begin()) | |
# Start finding the region at the beginning of the next line | |
region = self.view.indented_region(line.b + 2) | |
if region.contains(line.b): | |
# there is no subtree | |
return sublime.Region(-1, -1) | |
if not region.empty(): | |
region = sublime.Region(line.a, region.b) | |
return region |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment