diff --git a/journal.py b/journal.py index 7241ac7..4c6356d 100644 --- a/journal.py +++ b/journal.py @@ -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, diff --git a/migrations/2021-06-29_tag.py b/migrations/2021-06-29_tag.py new file mode 100644 index 0000000..68c7ad1 --- /dev/null +++ b/migrations/2021-06-29_tag.py @@ -0,0 +1,34 @@ +from copy import deepcopy +from pathlib import Path +from shutil import copy +import json + +journal_path = Path.home() / '.journal.json' + +copy(str(journal_path), str(journal_path.with_suffix('.bkp'))) + +journal = json.loads(journal_path.read_text()) +new_journal = deepcopy(journal) + +for day in journal['days']: + new_entries = [] + for entry in journal['days'][day]['entries']: + new_blocks = [] + for block in entry['blocks']: + if not isinstance(block, str) and block['type'] == 'hide': + if len(new_blocks) and not isinstance(new_blocks[-1], str) and \ + new_blocks[-1]['type'] != 'tag': + new_blocks.append({'type': 'tag', 'value': ['hide']}) + elif not isinstance(block, str) and block['type'] == 'info': + new_blocks.append({'type': 'tag', 'value': ['info']}) + new_blocks.append('\n') + new_blocks.append(block['value']) + else: + new_blocks.append(block) + + entry['blocks'] = new_blocks + new_entries.append(entry) + + new_journal['days'][day]['entries'] = new_entries + +journal_path.write_text(json.dumps(new_journal))