This commit is contained in:
olari
2021-07-05 14:20:55 +03:00
parent 02df9b9b5f
commit 1e352c7b06
2 changed files with 130 additions and 106 deletions

View File

@@ -17,37 +17,31 @@ import traceback
### GLOBALS
JOURNAL_PATH = Path.home() / '.journal.json'
T = TypeVar('T')
AnyCallable = Callable[..., Any]
AnyDict = dict[str, Any]
Journal = AnyDict
### UTILS
def nth_or_default(n: int, l: list[T], default: T):
def nth_or_default(n, l, default):
return l[n] if n < len(l) else default
def apply(f: AnyCallable, x: Any) -> Any:
def apply(f, x):
return f(x)
def flip(f: AnyCallable) -> AnyCallable:
def flip(f):
return lambda a1, a2: f(a2, a1)
def compose(*fns: AnyCallable) -> Any:
def compose(*fns):
return partial(reduce, flip(apply), fns)
def wrap_text(text: str, columns: int = 80) -> str:
def wrap_text(text, columns=80):
return textwrap.fill(text, columns,
replace_whitespace=False,
break_on_hyphens=False,
break_long_words=False)
def split_keep(delims: list[str], string: str) -> list[str]:
res: list[str] = []
buf: list[str] = []
def split_keep(delims, string):
res = []
buf = []
def flush_buf():
nonlocal res, buf
@@ -66,8 +60,8 @@ def split_keep(delims: list[str], string: str) -> list[str]:
return res
def merge_if(pred: Callable[[str, str], bool], l: list[str]) -> list[str]:
res: list[str] = []
def merge_if(pred, l):
res = []
for i, curr in enumerate(l):
prev = l[i-1] if i-1 >= 0 else None
@@ -78,10 +72,10 @@ def merge_if(pred: Callable[[str, str], bool], l: list[str]) -> list[str]:
return res
def open_editor(fpath: Path) -> None:
def open_editor(fpath):
run(['nvim', '+', str(fpath)])
def edit_text(text: str, suffix: str = '') -> str:
def edit_text(text, suffix=''):
fpath = Path(mktemp(suffix=suffix))
fpath.write_text(text)
@@ -94,56 +88,56 @@ def edit_text(text: str, suffix: str = '') -> str:
return text
def prompt(text: str) -> bool:
def prompt(text):
return input(text + ' [y/n] ') == 'y'
### DATE UTILS
def parse_date(date: str) -> datetime:
def parse_date(date):
return datetime.strptime(date, '%Y-%m-%d')
def format_date(date: datetime) -> str:
def format_date(date):
return date.strftime('%Y-%m-%d')
def today() -> datetime:
def today():
return datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
def evaluate_time_expression(expression: str) -> Optional[datetime]:
def evaluate_time_expression(expression):
if expression == 'today':
return datetime.now()
elif expression == 'yesterday':
return datetime.now() - timedelta(days=1)
def get_abbr_for_weekday(date: str) -> str:
def get_abbr_for_weekday(date):
return {
0: 'mo', 1: 'tu', 2: 'we', 3: 'th',
4: 'fr', 5: 'sa', 6: 'su',
}[parse_date(date).weekday()]
def parse_timestamp(timestamp: str) -> int:
def parse_timestamp(timestamp):
return int(datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S').timestamp())
def format_timestamp(timestamp: int) -> str:
def format_timestamp(timestamp):
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
### FILE PARSERS
def parse_foods_file(text: str) -> tuple[AnyDict, AnyDict]:
def parse_foods_file(text):
foods_str, recipes_str = text.split('---')
def parse_macro(macro: str) -> tuple[str, float]:
def parse_macro(macro):
name, value = macro.split()
return (name, float(value.removesuffix('g').removesuffix('kcal')))
foods: AnyDict = {
foods = {
macros[0]: dict(parse_macro(macro) for macro in macros[1:])
for macros in [food.split('\n') for food in foods_str.strip().split('\n\n')]
}
recipes: AnyDict = {}
recipes = {}
def evaluate_ingredients(ingredients: list[str]) -> AnyDict:
result: dict[str, float] = {}
def evaluate_ingredients(ingredients):
result = {}
total_weight = 0.0
for ingredient in ingredients:
@@ -177,7 +171,7 @@ def parse_foods_file(text: str) -> tuple[AnyDict, AnyDict]:
return foods, recipes
def evaluate_food_entry(foods: AnyDict, recipes: AnyDict, value: float, name: str) -> AnyDict:
def evaluate_food_entry(foods, recipes, value, name):
if name in recipes:
food = recipes[name]
@@ -197,8 +191,8 @@ def evaluate_food_entry(foods: AnyDict, recipes: AnyDict, value: float, name: st
return food
def parse_tasks_file(text: str) -> list[tuple[list[str], str]]:
result: list[tuple[list[str], str]] = []
def parse_tasks_file(text):
result = []
for task in text.splitlines():
days, name = task.split(':')
@@ -207,20 +201,20 @@ def parse_tasks_file(text: str) -> list[tuple[list[str], str]]:
return result
def get_godword(journal: Journal) -> list[str]:
def get_godword(journal):
return journal['files']['godword'].strip().split('\n')
def get_habits(journal: Journal) -> list[str]:
def get_habits(journal):
return journal['files']['habits'].strip().split('\n')
def get_tasks(journal: Journal) -> list[tuple[list[str], str]]:
def get_tasks(journal):
return parse_tasks_file(journal['files']['tasks'])
### HACKY HACKERY
_global_do_not_use: Journal = {}
_global_do_not_use = {}
def init_hacky_hackery(journal: Journal) -> None:
def init_hacky_hackery(journal):
global _global_do_not_use
_global_do_not_use = journal
@@ -229,10 +223,8 @@ def get_foods_file():
### HEADER MODULES
Notification = dict[str, Union[int, str]]
def get_notifications_for_date(journal: Journal, date: str) -> list[Notification]:
notifications: list[Notification] = []
def get_notifications_for_date(journal, date):
notifications = []
for day in journal['days'].values():
for entry in day.get('entries'):
@@ -246,20 +238,14 @@ def get_notifications_for_date(journal: Journal, date: str) -> list[Notification
return notifications
def get_yesterdays_sticky(journal: Journal, date: str) -> Optional[str]:
def get_yesterdays_sticky(journal, date):
yesterday = format_date(parse_date(date) - timedelta(days=1))
if day := journal['days'].get(yesterday):
if sticky := day['header'].get('sticky'):
return sticky
HeaderModule = tuple[
Callable[[Journal, str], Any],
Callable[[str], Any],
Callable[[Any], str],
]
header_modules: dict[str, HeaderModule] = {
header_modules = {
'godword': (
lambda j, d: [random.choice(get_godword(j)) for _ in range(20)],
lambda b: b.split(),
@@ -306,18 +292,18 @@ header_modules: dict[str, HeaderModule] = {
)
}
def create_header_module(name: str, journal: Journal, date: str) -> AnyDict:
def create_header_module(name, journal, date):
return header_modules[name][0](journal, date)
def parse_header_module(name: str, block: str) -> AnyDict:
def parse_header_module(name, block):
return header_modules[name][1](block)
def generate_header_module(name: str, value: AnyDict) -> str:
def generate_header_module(name, value):
return header_modules[name][2](value)
### ENTRY MODULES
def parse_timer(block: str) -> AnyDict:
def parse_timer(block):
rest = block.split()
name = None
@@ -327,7 +313,7 @@ def parse_timer(block: str) -> AnyDict:
if len(rest) > 1:
timestamp = parse_timestamp(' '.join(rest))
result: AnyDict = {}
result = {}
if name:
result['name'] = name
@@ -336,8 +322,8 @@ def parse_timer(block: str) -> AnyDict:
return result
def generate_timer(value: AnyDict) -> str:
parts: list[str] = []
def generate_timer(value):
parts = []
if name := value.get('name'):
parts.append(name)
@@ -347,7 +333,7 @@ def generate_timer(value: AnyDict) -> str:
return ' '.join(parts)
def parse_exercise(block: str) -> AnyDict:
def parse_exercise(block):
parts = block.split()
if parts[0] == 'walk':
@@ -371,7 +357,7 @@ def parse_exercise(block: str) -> AnyDict:
assert False
def generate_exercise(value: AnyDict) -> str:
def generate_exercise(value):
if value['kind'] == 'walk':
return f'walk {value["minutes"]}min {value["distance"]}km {value["steps"]}steps'
elif value['kind'] == 'calisthenics':
@@ -379,10 +365,10 @@ def generate_exercise(value: AnyDict) -> str:
assert False
DEFAULT_PARSER: AnyCallable = lambda b: {'value': b}
DEFAULT_GENERATOR: AnyCallable = lambda b: b['value']
DEFAULT_PARSER = lambda b: {'value': b}
DEFAULT_GENERATOR = lambda b: b['value']
entry_modules: dict[str, tuple[Callable[[str], AnyDict], Callable[[AnyDict], str]]] = {
entry_modules = {
'diet': (
lambda b: {'amount': int(b.split()[0].removesuffix('g')), 'food': b.split()[1].strip()},
lambda v: f'{v["amount"]}g {v["food"]}'),
@@ -416,15 +402,20 @@ entry_modules: dict[str, tuple[Callable[[str], AnyDict], Callable[[AnyDict], str
),
lambda v: f'{v["day"]} {v["message"]}'
),
'tag': (
lambda b: {'value': b.split(',')},
lambda v: ','.join(v['value'])
)
}
def parse_entry_module(block: str) -> AnyDict:
def parse_entry_module(block):
tag = block.split()[0].removeprefix('@')
block = block.removeprefix(f'@{tag}').strip()
return {'type': tag} | entry_modules[tag][0](block)
def generate_entry_module(block: AnyDict) -> str:
def generate_entry_module(block):
if block['type'] == 'notes':
return f'@notes\n{entry_modules[block["type"]][1](block)}'
@@ -432,7 +423,7 @@ def generate_entry_module(block: AnyDict) -> str:
### READ-ONLY STATS SECTION FUNCTIONS
def generate_stats(page: AnyDict) -> str:
def generate_stats(page):
if not page['entries']:
return ''
@@ -493,32 +484,32 @@ def generate_stats(page: AnyDict) -> str:
### PAGE FUNCTIONS
def create_header(journal: Journal, date: str) -> AnyDict:
def create_header(journal, date):
return {
module: create_header_module(module, journal, date)
for module in header_modules
}
def create_entry(journal: Journal, date: str) -> AnyDict:
def create_entry(journal, date):
return {
'timestamp': int(today().timestamp()),
'blocks': []
}
def create_day(journal: Journal, date: str) -> AnyDict:
def create_day(journal, date):
return {
'title': date,
'header': create_header(journal, date),
'entries': []
}
def parse_header(text: str) -> AnyDict:
def split_into_blocks(text: str) -> list[str]:
def parse_header(text):
def split_into_blocks(text):
return [b.strip() for b in re.split(r'\n{2,}', text) if b.strip() != '']
modules = split_into_blocks(text)
result: AnyDict = {}
result = {}
for module in modules:
name, block = module.split('\n', maxsplit=1)
@@ -527,7 +518,7 @@ def parse_header(text: str) -> AnyDict:
return result
def generate_header(header: AnyDict) -> str:
def generate_header(header):
result = ''
for name, header in header.items():
@@ -539,9 +530,9 @@ def generate_header(header: AnyDict) -> str:
return result
def parse_entry(timestamp: str, content: str) -> AnyDict:
def merge_notes_block(l: list[str]) -> list[str]:
res: list[str] = []
def parse_entry(timestamp, content):
def merge_notes_block(l):
res = []
i = 0
while i < len(l):
@@ -555,12 +546,12 @@ def parse_entry(timestamp: str, content: str) -> AnyDict:
return res
def merge_wrapped_lines(l: list[str]) -> list[str]:
def merge_wrapped_lines(l):
TIMESTAMP_LENGTH = len('2020-02-02 02:02:02 ')
POST_BLOCK_LENGTH = len('@post 2020-02-02 02:02:02 ')
COLUMN_LIMIT = 80
res: list[str] = []
res = []
i = 0
while i < len(l):
@@ -597,8 +588,8 @@ def parse_entry(timestamp: str, content: str) -> AnyDict:
return res
def split_post_block(l: list[str]) -> list[str]:
res: list[str] = []
def split_post_block(l):
res = []
POST_BLOCK_LENGTH = len('@post 2020-02-02 02:02:02')
@@ -651,12 +642,12 @@ def parse_entry(timestamp: str, content: str) -> AnyDict:
return {
'timestamp': parse_timestamp(timestamp.strip()),
'blocks': [parse_entry_module(b) if b.startswith('@') else b for b in split_into_blocks(content)],
'blocks': [(parse_entry_module(b) if b.startswith('@') else b) for b in split_into_blocks(content)],
}
def generate_entry(entry: AnyDict) -> str:
def generate_entry(entry):
def format_block(curr: Any, prev: Any, before_prev: Any):
def format_text(text: str) -> str:
def format_text(text):
if all(c == '\n' for c in curr):
return text
@@ -664,7 +655,7 @@ def generate_entry(entry: AnyDict) -> str:
DUMMY_POST = '@post 2020-02-02 02:02:02 '
is_first = not prev
is_post: bool = (before_prev and all(c == '\n' for c in before_prev) and isinstance(prev, dict) and prev['type'] == 'post')
is_post = (before_prev and all(c == '\n' for c in before_prev) and isinstance(prev, dict) and prev['type'] == 'post')
if is_first:
text = DUMMY_TS + text
@@ -706,7 +697,7 @@ def generate_entry(entry: AnyDict) -> str:
return result
def parse_day(text: str) -> AnyDict:
def parse_day(text):
# discard read-only QS section
text = text[text.find('#'):]
@@ -725,7 +716,7 @@ def parse_day(text: str) -> AnyDict:
'entries': [parse_entry(timestamp, content) for timestamp, content in entries],
}
def generate_day(day: AnyDict) -> str:
def generate_day(day):
result = ''
result += generate_stats(day)
@@ -743,13 +734,13 @@ def generate_day(day: AnyDict) -> str:
### COMMAND UTILS
def load_journal() -> Journal:
def load_journal():
return json.loads(JOURNAL_PATH.read_text())
def save_journal(journal: Journal) -> None:
def save_journal(journal):
JOURNAL_PATH.write_text(json.dumps(journal))
def import_journal(fpath: Path) -> Journal:
def import_journal(fpath):
return {
'days': {
fname.stem: parse_day(fname.read_text())
@@ -761,14 +752,14 @@ def import_journal(fpath: Path) -> Journal:
}
}
def export_journal(journal: Journal, fpath: Path) -> None:
def export_journal(journal, fpath):
for day in journal['days'].values():
(fpath / (day['title'] + '.md')).write_text(generate_day(day))
for fname, content in journal['files'].items():
(fpath / fname).write_text(content)
def backup_journal(journal: Journal) -> Path:
def backup_journal(journal):
print('Creating backup...')
tmpdir = Path(mkdtemp())
@@ -795,7 +786,7 @@ def backup_journal(journal: Journal) -> Path:
return zipfile_path
def open_journal(journal: Journal, date: str) -> Journal:
def open_journal(journal, date):
if not journal['days'].get(date):
backup_journal(journal)
journal['days'][date] = create_day(journal, date)
@@ -816,7 +807,7 @@ def open_journal(journal: Journal, date: str) -> Journal:
### COMMAND HANDLERS
def handle_open(args: list[str]) -> None:
def handle_open(args):
subcommand = nth_or_default(0, args, 'today')
if date := evaluate_time_expression(subcommand):
@@ -824,7 +815,7 @@ def handle_open(args: list[str]) -> None:
else:
print(f'Invalid subcommand: {subcommand}')
def handle_edit(args: list[str]) -> None:
def handle_edit(args):
subcommand = nth_or_default(0, args, 'foods')
journal = load_journal()
@@ -836,7 +827,7 @@ def handle_edit(args: list[str]) -> None:
save_journal(journal)
def handle_import(args: list[str]) -> None:
def handle_import(args):
if len(args) < 1:
print('Missing directory.')
return
@@ -849,7 +840,7 @@ def handle_import(args: list[str]) -> None:
save_journal(import_journal(path))
def handle_export(args: list[str]) -> None:
def handle_export(args):
if len(args) < 1:
print('Missing directory.')
return
@@ -862,7 +853,7 @@ def handle_export(args: list[str]) -> None:
export_journal(load_journal(), path)
def handle_test(args: list[str]) -> None:
def handle_test(args):
journal = load_journal()
journal_orig = deepcopy(journal)
@@ -879,11 +870,11 @@ def handle_test(args: list[str]) -> None:
else:
print('Test passed!')
def handle_summary(args: list[str]) -> None:
def generate_food_summary(day: AnyDict) -> str:
def handle_summary(args):
def generate_food_summary(day):
result = ''
def print(str:str=''):
def print(str=''):
nonlocal result
result += '\n' + str
@@ -954,24 +945,23 @@ def handle_summary(args: list[str]) -> None:
print(generate_food_summary(journal['days'][date]))
def handle_backup(args: list[str]) -> None:
def handle_backup(args):
archive_path = backup_journal(load_journal())
if prompt('Delete backup archive?'):
archive_path.unlink()
### MAIN
def main() -> None:
def main():
init_hacky_hackery(load_journal())
command = nth_or_default(1, sys.argv, 'open')
args = sys.argv[2:]
def handle_invalid(args: list[str]) -> None:
def handle_invalid(args):
print(f'Invalid command: {command}')
command_handlers: dict[str, Callable[[list[str]], None]] = {
command_handlers = {
'open': handle_open,
'edit': handle_edit,
'import': handle_import,