Skip to content

Commit

Permalink
Implement interactive edit command which uses grep
Browse files Browse the repository at this point in the history
This is the first step to implement the idea proposed in #33. Later it
can be extended to other commands.
  • Loading branch information
tpwo committed Nov 6, 2023
1 parent eddc979 commit 7abf1b3
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 30 deletions.
4 changes: 2 additions & 2 deletions src/pyzet/grep.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def grep(args: Namespace, config: Config) -> int:
grep_opts = _build_grep_options(
args.ignore_case, args.line_number, args.title
)
patterns = _parse_grep_patterns(args.patterns)
patterns = parse_grep_patterns(args.patterns)
grep_opts.extend(patterns)
return call_git(
config,
Expand All @@ -67,7 +67,7 @@ def _build_grep_options(
return opts


def _parse_grep_patterns(patterns: list[str]) -> list[str]:
def parse_grep_patterns(patterns: list[str]) -> list[str]:
opts = []
for idx, pat in enumerate(patterns):
if pat.startswith('-'):
Expand Down
97 changes: 74 additions & 23 deletions src/pyzet/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
from pyzet import utils
from pyzet.grep import define_grep_cli
from pyzet.grep import grep
from pyzet.grep import parse_grep_patterns
from pyzet.sample_config import define_sample_config_cli
from pyzet.sample_config import sample_config
from pyzet.utils import add_id_arg
from pyzet.utils import call_git
from pyzet.utils import Config
from pyzet.utils import get_git_output
Expand Down Expand Up @@ -98,7 +98,24 @@ def _get_parser() -> tuple[ArgumentParser, dict[str, ArgumentParser]]:
subparsers.add_parser('add', help='add a new zettel')

edit_parser = subparsers.add_parser('edit', help='edit an existing zettel')
add_id_arg(edit_parser)
edit_parser.add_argument(
'-i',
'--ignore-case',
action='store_true',
help='case insensitive matching',
)
edit_parser.add_argument(
'-p',
'--pretty',
action='store_true',
help='use prettier format for printing date and time',
)
edit_parser.add_argument(
'--tags',
action='store_true',
help='show tags for each zettel',
)
edit_parser.add_argument('patterns', nargs='*', help='grep patterns')

remove_parser = subparsers.add_parser('rm', help='remove a zettel')
remove_parser.add_argument('id', nargs=1, help='zettel id (timestamp)')
Expand Down Expand Up @@ -210,7 +227,7 @@ def _parse_args(args: Namespace) -> int:
try:
# show & edit commands use nargs="?" which makes
# args.command str rather than single element list.
if args.command in {'show', 'edit'}:
if args.command in {'show'}:
id_ = args.id
else:
id_ = args.id[0]
Expand Down Expand Up @@ -292,9 +309,6 @@ def _parse_args_with_id(
if command == 'show':
return show.command(args, config, id_)

if command == 'edit':
return edit_zettel(id_, config, config.editor)

if command == 'rm':
return remove_zettel(id_, config)

Expand All @@ -312,6 +326,9 @@ def _parse_args_without_id(args: Namespace, config: Config) -> int:
if args.command == 'add':
return add_zettel(config)

if args.command == 'edit':
return edit_zettel(args, config)

if args.command == 'list':
return list_zettels(args, config.repo)

Expand Down Expand Up @@ -376,8 +393,11 @@ def _get_zettel_repr(zettel: Zettel, args: Namespace) -> str:
pass
if args.pretty:
return f'{get_timestamp(zettel.id_)} -- {zettel.title}{tags}'
if args.link:
return get_md_relative_link(zettel.id_, zettel.title)
try:
if args.link:
return get_md_relative_link(zettel.id_, zettel.title)
except AttributeError: # 'Namespace' object has no attribute 'link'
pass
return f'{zettel.id_} -- {zettel.title}{tags}'


Expand Down Expand Up @@ -482,41 +502,72 @@ def add_zettel(config: Config) -> int:
return 0


def edit_zettel(id_: str, config: Config, editor: str) -> int:
def edit_zettel(args: Namespace, config: Config) -> int:
"""Edits zettel and commits changes with 'ED:' in the message."""
zettel_path = Path(config.repo, C.ZETDIR, id_, C.ZETTEL_FILENAME)
_open_file(zettel_path, config)
opts = ['-I', '--all-match', '--name-only']
if args.ignore_case:
opts.append('--ignore-case')
opts.extend(
[*parse_grep_patterns(args.patterns), '--', f'*/{C.ZETTEL_FILENAME}']
)
try:
out = get_git_output(config, 'grep', opts).decode()
except subprocess.CalledProcessError:
raise SystemExit('ERROR: no zettels found')

matches = {}
for idx, filename in enumerate(out.splitlines(), start=1):
path = Path(config.repo, filename)
matches[idx] = get_zettel(path)

print(f'Found {len(matches)} matches:')
for idx, zettel in matches.items():
print(f'[{idx}] {_get_zettel_repr(zettel, args)}')

try:
zettel = get_zettel(zettel_path.parent)
user_input = input('Open (press enter to cancel): ')
except KeyboardInterrupt:
raise SystemExit('\naborting')

if user_input == '':
raise SystemExit('aborting')
try:
zettel = matches[int(user_input)]
except KeyError:
raise SystemExit('ERROR: wrong zettel ID')

_open_file(zettel.path, config)

try:
zettel = get_zettel(zettel.path)
except ValueError:
logging.info(
f"edit: zettel modification aborted '{zettel_path.absolute()}'"
f"edit: zettel modification aborted '{zettel.path.absolute()}'"
)
print('Editing zettel aborted, restoring the version from git...')
call_git(config, 'restore', (zettel_path.as_posix(),))
call_git(config, 'restore', (zettel.path.as_posix(),))
else:
if _file_was_modified(zettel_path, config):
if _file_was_modified(zettel.path, config):
output = _get_files_touched_last_commit(config).decode('utf-8')
if output == f'{C.ZETDIR}/{id_}/{C.ZETTEL_FILENAME}\n':
if output == f'{C.ZETDIR}/{zettel.id_}/{C.ZETTEL_FILENAME}\n':
# If we touch the same zettel as in the last commit,
# than we automatically squash the new changes with the
# last commit, so the Git history can be simplified.
call_git(config, 'add', (zettel_path.as_posix(),))
call_git(config, 'add', (zettel.path.as_posix(),))
call_git(config, 'commit', ('--amend', '--no-edit'))
print(
f'{id_} was edited and auto-squashed with the last commit'
'\nForce push might be required'
f'{zettel.id_} was edited and auto-squashed with the last'
' commit\nForce push might be required'
)
else:
_commit_zettel(
config,
zettel_path,
_get_edit_commit_msg(zettel_path, zettel.title, config),
zettel.path,
_get_edit_commit_msg(zettel.path, zettel.title, config),
)
print(f'{id_} was edited')
print(f'{zettel.id_} was edited')
else:
print(f"{id_} wasn't modified")
print(f"{zettel.id_} wasn't modified")
return 0


Expand Down
11 changes: 8 additions & 3 deletions src/pyzet/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import sys
from argparse import ArgumentParser
from pathlib import Path
from typing import Iterable
from typing import NamedTuple


Expand All @@ -20,7 +21,7 @@ class Config(NamedTuple):
def call_git(
config: Config,
command: str,
options: tuple[str, ...] = (),
options: Iterable[str] = (),
path: Path | None = None,
) -> int:
if path is None:
Expand All @@ -34,7 +35,7 @@ def call_git(
def get_git_remote_url(
config: Config,
origin: str,
options: tuple[str, ...] = (),
options: Iterable[str] = (),
) -> str:
opts = ('get-url', origin, *options)
remote = get_git_output(config, 'remote', opts).decode().strip()
Expand All @@ -46,14 +47,18 @@ def get_git_remote_url(


def get_git_output(
config: Config, command: str, options: tuple[str, ...]
config: Config, command: str, options: Iterable[str]
) -> bytes:
repo = config.repo.as_posix()
cmd = (_get_git_bin(), '-C', repo, command, *options)
logging.debug(f'get_git_output: subprocess.run({cmd})')
try:
return subprocess.run(cmd, capture_output=True, check=True).stdout
except subprocess.CalledProcessError as err:
if command == 'grep':
# Grep returns non-zero exit code if no match,
# but without any error msg
raise
git_err_prefix = 'error: '
errmsg = err.stderr.decode().strip().partition(git_err_prefix)[-1]
raise SystemExit(f'GIT ERROR: {errmsg}') from err
Expand Down
6 changes: 4 additions & 2 deletions tests/main_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
import subprocess
from pathlib import Path
from unittest import mock

import pytest

Expand Down Expand Up @@ -124,14 +125,15 @@ def test_init_error_config_file_missing():

def test_edit_error_editor_not_found():
with pytest.raises(SystemExit) as excinfo:
main(['-c', 'testing/pyzet-wrong.yaml', 'edit'])
with mock.patch('builtins.input', return_value='1'):
main(['-c', 'testing/pyzet-wrong.yaml', 'edit', 'zet test entry'])
(msg,) = excinfo.value.args
assert msg == "ERROR: editor 'not-vim' cannot be found."


def test_edit_error_missing_repo_in_yaml():
with pytest.raises(SystemExit) as excinfo:
main(['-c', 'testing/pyzet-missing-repo.yaml', 'edit'])
main(['-c', 'testing/pyzet-missing-repo.yaml', 'edit', 'foo-pattern'])
(msg,) = excinfo.value.args
assert (
msg == "ERROR: field 'repo' missing from"
Expand Down

0 comments on commit 7abf1b3

Please sign in to comment.