# This file exists within 'dob':
#
# https://github.com/hotoffthehamster/dob
#
# Copyright © 2018-2020 Landon Bouma. All rights reserved.
#
# 'dob' is free software: you can redistribute it and/or modify it under the terms
# of the GNU General Public License as published by the Free Software Foundation,
# either version 3 of the License, or (at your option) any later version.
#
# 'dob' is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
# PURPOSE. See the GNU General Public License for more details.
#
# You can find the GNU General Public License reprinted in the file titled 'LICENSE',
# or visit <http://www.gnu.org/licenses/>.
import os
import random
import re
import sys
from gettext import gettext as _
import click_hotoffthehamster as click
from click_hotoffthehamster.parser import split_arg_string
from nark.helpers.parsing import (
ParserMissingActivityException,
ParserMissingDatetimeOneException,
ParserMissingDatetimeTwoException
)
from nark.items.fact import Fact
__all__ = ('tab_complete', )
TIME_HINT_MAP = {
# SYNC_ME: RE_TIME_HINT, TIME_HINT_MAP, and @generate_add_fact_command's.
'on': 'verify_none',
'now': 'verify_none', # 'on' alias
'from': 'verify_both',
'between': 'verify_both', # 'from' alias
'at': 'verify_start',
'to': 'verify_end',
'until': 'verify_end', # 'to' alias
'then': 'verify_then',
'still': 'verify_still',
'after': 'verify_none',
'next': 'verify_none', # 'after' alias
}
# FIXME/2018-05-17 18:04: On `dob import <tab>`, showing commands, not files! why??
[docs]def tab_complete(controller):
def _complete_cmd(controller):
args, incomplete = _do_complete()
if len(args) < 1:
return
try:
time_hint = TIME_HINT_MAP[args[0]]
except KeyError:
# Not an add_fact command! Fall-back to Click's complete.
return
for item in _get_choices(args[1:], incomplete, time_hint):
click.echo(item)
# NOTE: (lb): This fcn. was copied from click/_bashcomplete.py::do_complete().
# Then it was modified.
def _do_complete():
try:
cwords = split_arg_string(os.environ['COMP_WORDS'])
except KeyError:
controller.client_logger.error(_(
'Expected environ "COMP_WORDS" not set.'
))
sys.exit(1)
try:
cword = int(os.environ['COMP_CWORD'])
except ValueError:
# This should only happen if your testing the complete command.
assert os.environ['COMP_CWORD'] == ''
cword = 0
args = cwords[1:cword]
try:
incomplete = cwords[cword]
except IndexError:
incomplete = ''
return (args, incomplete)
def _get_choices(args, incomplete, time_hint):
choices = []
try:
# Meh. Probably don't need FactDressed.
fact, _err = Fact.create_from_factoid(args, time_hint=time_hint)
except ParserMissingDatetimeOneException as err:
choices = _choices_datetimes(controller, incomplete, time_hint, err)
except ParserMissingDatetimeTwoException as err:
choices = _choices_datetimes(controller, incomplete, time_hint, err)
except ParserMissingActivityException:
# See if we should suggest tags, activities, or activity-categories.
if (not incomplete) or (not re.match(r'''^['"]?[#@]''', incomplete)):
choices = choices_activities(
controller, incomplete, whitespace_ok=False,
)
else:
choices = choices_tags(
controller, incomplete, whitespace_ok=False,
)
else:
choices = [
'Now_You_Write_A_Description',
'Where-do-hamsters-go-after-graduation?__Hamsterdam!',
]
return choices
# The shim to the inline main(), above.
_complete_cmd(controller)
def _choices_datetimes(controller, incomplete, time_hint, parser_err):
"""Suggest times."""
now = controller.now
# Show friendly usage reminders.
# - For 'verify_start' (dob-at), show now and very recent times.
# - For 'verify_end' (dob-to-/-until), show now (and very recent).
# - For 'verify_both', with show less freshly recent for start,
# and show more recent times for second, end time.
# NOTE: We underscore because Bash complete splits words,
# and thankfully the friendly `dateparser` understand this
# (for the most part; e.g., 'three years ago' works, but
# not 'three_years_ago', though '3_years_ago' works, go figure).
friendly_at_hints = [
'10_minutes_ago',
'5_seconds_ago',
'at_1_pm',
'an_hour_ago',
'noon',
'midnight',
]
friendly_from_hints = [
# (lb): The shell splits completes on whitespace, so use underscores.
# FIXME: (lb): Update parser to convert underscores back to whitespace
# before passing to human friendly time parser.
'yesterday_at_3_PM',
'1_week_ago',
'3_years_ago',
'Monday_at_16:44',
'at_noon'
'1_week_ago_at_midnight',
# Hahaha, futuristic!
# (lb): This is an invalid option. But it's funny!
'tomorrow',
]
# Show one friendly date example, randomly selected.
# (lb): Using random.shuffle, rather than random.choice. Can't remember why.
random.shuffle(friendly_at_hints)
random.shuffle(friendly_from_hints)
if time_hint == 'verify_start':
friendly_hint = friendly_at_hints[:2]
elif time_hint == 'verify_end':
friendly_hint = ['now']
elif time_hint == 'verify_both':
if isinstance(parser_err, ParserMissingDatetimeOneException):
friendly_hint = friendly_from_hints[:2]
else:
assert isinstance(parser_err, ParserMissingDatetimeTwoException)
friendly_hint = friendly_at_hints[:2]
friendly_hint.append('now')
choices = [
now.strftime('%Y-%m-%d'),
now.strftime('%H:%M'),
# Bash will split the completion word in a space,
# so use \S+ form of YYYY-MM-DD and %H:M.
# MEH: dateparser recognizes almost any delimiter
# between the YYYY-MM-DD and the %H:%M, except
# '-' and '+' indicate time zone. 'T' seems fine.
now.strftime('%Y-%m-%dT%H:%M'),
]
choices += friendly_hint
if incomplete:
choices = [
ch for ch in choices if not incomplete or ch.startswith(incomplete)
]
return choices
def choices_tags(controller, incomplete='', whitespace_ok=False):
"""Suggest tags."""
# Grab the last 20 or so tags used (by Facts, chronologically),
# and also the most used (top ten) 10 or so tags used by all Facts.
#
# FIXME: Make these limits settable (via config?).
# FIXME: Can we cycle through the various sort options?
# E.g., if user TABs, show list of previous used tags.
# And if user TABs a second time, show list of most
# use tags. TAB again, another sort option, etc.
# MAGIC_NUMBERS: 21 and 13, eh. Arbitrary limits.
tags_counts = controller.tags.get_all_by_usage(sort_cols=('start',), limit=21)
tags_counts += controller.tags.get_all_by_usage(sort_cols=('usage',), limit=13)
choices = [
'@{}'.format(tag.name) for tag, _uses, _span
in tags_counts if not incomplete or tag.name.startswith(incomplete[1:])
]
# MEH: We cull, even though we set limit above, so total count might be even smaller.
# (We could solve at the SQL query level, but who wants to go to the trouble?)
if not whitespace_ok:
choices = [tag for tag in choices if ' ' not in tag]
return choices
def choices_activities(
controller,
incomplete='',
whitespace_ok=False,
):
"""Suggest activities."""
acty = controller.activities.get_all_by_usage(sort_cols=('start'))
choices = [
'{}@{}'.format(
act.name, act.category.name if act.category else ''
) for act, count in acty
]
# (lb): Bash complete doesn't handle spaces well, so ignore
# those activities@categories with and spaces.
if not whitespace_ok:
choices = [atc for atc in choices if ' ' not in atc]
if incomplete:
choices = [atc for atc in choices if atc.startswith(incomplete)]
# FIXME/2018-05-15 23:35: Caller should specify max, or just deal with
# all results. E.g., tab-complete only wants so many. E.g., --ask only
# wants as many can fit screen (or all, if I implement pagination). (lb)
max_choices = 50
if len(choices) > max_choices:
choices = choices[:50]
return choices