Initial commit
This commit is contained in:
commit
f6397185c4
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
__pycache__
|
||||||
|
poetry.lock
|
||||||
0
pyn/__init__.py
Normal file
0
pyn/__init__.py
Normal file
29
pyn/cli.py
Normal file
29
pyn/cli.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from pyn.common import parser, logger, setup_logging
|
||||||
|
from pyn.config import load_config
|
||||||
|
|
||||||
|
from pyn import commands, todo # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
options = vars(parser.parse_args())
|
||||||
|
|
||||||
|
setup_logging(options.pop("logging"))
|
||||||
|
logger.debug(options)
|
||||||
|
|
||||||
|
options["config"] = load_config(options.pop("config"))
|
||||||
|
logger.debug(options["config"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
f = options.pop("func")
|
||||||
|
except KeyError:
|
||||||
|
parser.print_usage()
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
f(**options)
|
||||||
|
except RuntimeError as e:
|
||||||
|
parser.exit(e)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
92
pyn/commands.py
Normal file
92
pyn/commands.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
from pyn.common import parser, logger
|
||||||
|
from pyn.config import Config
|
||||||
|
from pyn import notes
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
group = parser.add_subparsers(help="available commands")
|
||||||
|
|
||||||
|
### Add a note
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_add_note(config, title, notebook):
|
||||||
|
filename = notes.get_note_filename(config, title, notebook)
|
||||||
|
logger.info("Adding note: %s", filename)
|
||||||
|
|
||||||
|
body = "" if sys.stdin.isatty() else sys.stdin.read()
|
||||||
|
|
||||||
|
notes.append_template(config, filename, "note", title=title, body=body)
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
notes.edit_file(config, filename)
|
||||||
|
|
||||||
|
|
||||||
|
add_note = group.add_parser("add", aliases=["a"])
|
||||||
|
add_note.add_argument("--notebook", "-n", help="add to this notebook [general]")
|
||||||
|
add_note.add_argument("title", help="title for note")
|
||||||
|
add_note.set_defaults(func=cmd_add_note)
|
||||||
|
|
||||||
|
|
||||||
|
### Delete a note
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_del_note(config: Config, title: str, notebook: str, yes: bool) -> None:
|
||||||
|
filename = notes.search_filenames(config, title, notebook)
|
||||||
|
print(f"Removing: {filename}")
|
||||||
|
|
||||||
|
if not yes:
|
||||||
|
confirm = input("Are you sure [y/N]: ")
|
||||||
|
if confirm.lower() != "y":
|
||||||
|
raise RuntimeError("User cancelled")
|
||||||
|
os.unlink(filename)
|
||||||
|
|
||||||
|
|
||||||
|
del_note = group.add_parser("delete", aliases=["del"])
|
||||||
|
del_note.add_argument("--notebook", "-n", help="add to this notebook [general]")
|
||||||
|
del_note.add_argument("--yes", action="store_true", help="dont prompt for confirmation")
|
||||||
|
del_note.add_argument("title", help="title for note", nargs="?")
|
||||||
|
del_note.set_defaults(func=cmd_del_note)
|
||||||
|
|
||||||
|
### List notes
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_notes(config: Config, notebook: str, pretty: bool) -> None:
|
||||||
|
for filename in notes.get_file_list(config, notebook):
|
||||||
|
if pretty:
|
||||||
|
e = notes.file_entry(config, filename)
|
||||||
|
print(f"{e.notebook:>20s}: {e.filename}")
|
||||||
|
else:
|
||||||
|
print(filename)
|
||||||
|
|
||||||
|
|
||||||
|
list_notes = group.add_parser("list", aliases=["l"])
|
||||||
|
list_notes.add_argument("--notebook", "-n", help="limit to this notebook")
|
||||||
|
list_notes.add_argument("--pretty", "-p", action="store_true", help="pretty printing")
|
||||||
|
list_notes.set_defaults(func=cmd_list_notes)
|
||||||
|
|
||||||
|
### Edit a note
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_edit_note(config, title, notebook: str) -> None:
|
||||||
|
filename = notes.search_filenames(config, title, notebook)
|
||||||
|
notes.edit_file(config, filename)
|
||||||
|
|
||||||
|
|
||||||
|
edit_note = group.add_parser("edit", aliases=["e"])
|
||||||
|
edit_note.add_argument("title", help="note title", nargs="?")
|
||||||
|
edit_note.add_argument("--notebook", "-n", help="limit to this notebook")
|
||||||
|
edit_note.set_defaults(func=cmd_edit_note)
|
||||||
|
|
||||||
|
### Show a note
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_show_note(config, title, notebook: str) -> None:
|
||||||
|
filename = notes.search_filenames(config, title, notebook)
|
||||||
|
notes.cat_files(filename)
|
||||||
|
|
||||||
|
|
||||||
|
show_note = group.add_parser("show", aliases=["s", "cat"])
|
||||||
|
show_note.add_argument("title", help="note title", nargs="?")
|
||||||
|
show_note.add_argument("--notebook", "-n", help="limit to this notebook")
|
||||||
|
show_note.set_defaults(func=cmd_show_note)
|
||||||
21
pyn/common.py
Normal file
21
pyn/common.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("pyn")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level="info"):
|
||||||
|
logging.basicConfig(level=getattr(logging, level.upper()))
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Tool to manipulate text notes")
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
"-c",
|
||||||
|
default="~/.config/pyn/config.json",
|
||||||
|
help="config file [~/.config/pyn/config.json]",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--logging", "-l", default="info", help="change logging level [INFO]"
|
||||||
|
)
|
||||||
28
pyn/config.py
Normal file
28
pyn/config.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
directory: pathlib.Path
|
||||||
|
default_notebook: str = "general"
|
||||||
|
editor: list[str] = field(default_factory=lambda: ["vim", "-c", ":norm G2k$"])
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str):
|
||||||
|
try:
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
user_config = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
user_config = {}
|
||||||
|
|
||||||
|
user_config["directory"] = pathlib.Path(
|
||||||
|
user_config.get("directory", "~/Documents/notes")
|
||||||
|
).expanduser()
|
||||||
|
config = Config(**user_config)
|
||||||
|
|
||||||
|
if not config.directory.is_dir():
|
||||||
|
config.directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return config
|
||||||
129
pyn/notes.py
Normal file
129
pyn/notes.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import subprocess
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from pyn.config import Config
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from shutil import copyfileobj
|
||||||
|
from collections import namedtuple
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
Entry = namedtuple("Entry", ("notebook", "filename"))
|
||||||
|
SearchResult = namedtuple("SearchResult", ("filename", "line_number", "snippet"))
|
||||||
|
|
||||||
|
NOTE_HEADING = """---
|
||||||
|
date: {date}
|
||||||
|
notebook: {notebook}
|
||||||
|
---"""
|
||||||
|
|
||||||
|
TEMPLATES = {
|
||||||
|
"heading": NOTE_HEADING,
|
||||||
|
"note": "\n\n# {title}\n\n{body}\n---",
|
||||||
|
"todo": "\n* [ ] {item}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(s):
|
||||||
|
return s.lower().replace(" ", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def get_note_filename(
|
||||||
|
config: Config, title: str, notebook: str | None = None, create: bool = True
|
||||||
|
) -> pathlib.Path:
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
notebook = slugify(notebook or config.default_notebook)
|
||||||
|
|
||||||
|
notebook_path = config.directory / notebook
|
||||||
|
note_path = (
|
||||||
|
notebook_path / f"{now.year}-{now.month:02d}-{now.day:02d}-{slugify(title)}.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not note_path.is_file() and create:
|
||||||
|
notebook_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
note_path.touch()
|
||||||
|
append_template(
|
||||||
|
config,
|
||||||
|
note_path,
|
||||||
|
"heading",
|
||||||
|
date=now.isoformat(timespec="seconds"),
|
||||||
|
notebook=notebook,
|
||||||
|
)
|
||||||
|
|
||||||
|
return note_path
|
||||||
|
|
||||||
|
|
||||||
|
def append_template(
|
||||||
|
config: Config, filename: pathlib.Path, template: str, **context: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
rendered = TEMPLATES[template].format(**context)
|
||||||
|
with open(filename, "a") as f:
|
||||||
|
f.write(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_file(config: Config, filename: pathlib.Path) -> None:
|
||||||
|
cmd = config.editor.copy()
|
||||||
|
cmd.append(filename)
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_list(config: Config, notebook: str = None) -> list[pathlib.Path]:
|
||||||
|
root = config.directory / notebook if notebook else config.directory
|
||||||
|
|
||||||
|
for d, _, files in root.walk():
|
||||||
|
for filename in files:
|
||||||
|
yield d / filename
|
||||||
|
|
||||||
|
|
||||||
|
def search_filenames(config: Config, title: str, notebook: str = None) -> pathlib.Path:
|
||||||
|
root = config.directory / notebook if notebook else config.directory
|
||||||
|
|
||||||
|
cmd = ["fzf", "-1", "-i"]
|
||||||
|
if title:
|
||||||
|
cmd.extend(["-q", title])
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(cmd, cwd=root)
|
||||||
|
file_path = root / output.decode("utf-8").strip()
|
||||||
|
return file_path
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
if e.returncode == 130:
|
||||||
|
raise RuntimeError("User cancelled")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def file_entry(config: Config, filepath: pathlib.Path) -> Entry:
|
||||||
|
relative = filepath.relative_to(config.directory)
|
||||||
|
return Entry(str(relative.parent), relative.name)
|
||||||
|
|
||||||
|
|
||||||
|
def cat_files(*filenames: list[pathlib.Path]) -> None:
|
||||||
|
for filename in filenames:
|
||||||
|
with open(filename, "r") as f:
|
||||||
|
copyfileobj(f, sys.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def search_contents(
|
||||||
|
config: Config,
|
||||||
|
query: str,
|
||||||
|
notebook: str = None,
|
||||||
|
file_matcher: str = None,
|
||||||
|
full_line: bool = False,
|
||||||
|
) -> list[SearchResult]:
|
||||||
|
root = config.directory / notebook if notebook else config.directory
|
||||||
|
|
||||||
|
cmd = ["grep", "-rFn", query, str(root)]
|
||||||
|
if file_matcher:
|
||||||
|
cmd.append(f"--include={file_matcher}")
|
||||||
|
if full_line:
|
||||||
|
cmd.append("-x")
|
||||||
|
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(cmd)
|
||||||
|
for line in output.decode("utf-8").strip().split("\n"):
|
||||||
|
filename, lineno, snippet = line.split(":", 2)
|
||||||
|
yield SearchResult(pathlib.Path(filename), int(lineno), snippet)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
if e.returncode == 1:
|
||||||
|
return
|
||||||
|
raise
|
||||||
94
pyn/todo.py
Normal file
94
pyn/todo.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
from pyn.common import parser, logger
|
||||||
|
from pyn.config import Config
|
||||||
|
from pyn import notes
|
||||||
|
from pyn.commands import group as parent_group
|
||||||
|
from shutil import get_terminal_size
|
||||||
|
|
||||||
|
|
||||||
|
p = parent_group.add_parser("todo", help="manipulate todo items")
|
||||||
|
group = p.add_subparsers()
|
||||||
|
|
||||||
|
### Add a todo
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_add_todo(config: Config, item: list[str], notebook: str, create: bool) -> None:
|
||||||
|
item = " ".join(item)
|
||||||
|
filename = notes.get_note_filename(config, "todo", notebook)
|
||||||
|
notes.append_template(config, filename, "todo", item=item)
|
||||||
|
|
||||||
|
|
||||||
|
add_todo = group.add_parser("add", aliases=["a"])
|
||||||
|
add_todo.add_argument("--notebook", "-n", help="add to this notebook [general]")
|
||||||
|
add_todo.add_argument(
|
||||||
|
"--create", "-c", action="store_true", help="start a new todo list"
|
||||||
|
)
|
||||||
|
add_todo.add_argument("item", help="item to add", nargs="*")
|
||||||
|
add_todo.set_defaults(func=cmd_add_todo)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list_pending(config: Config, notebook: str):
|
||||||
|
nb = None
|
||||||
|
cols, _ = get_terminal_size()
|
||||||
|
fmt = "{snippet:" + str(cols - 20) + "s} {name}"
|
||||||
|
for result in notes.search_contents(
|
||||||
|
config, "* [ ]", notebook, file_matcher="*-todo.md"
|
||||||
|
):
|
||||||
|
entry = notes.file_entry(config, result.filename)
|
||||||
|
if entry.notebook != nb:
|
||||||
|
nb = entry.notebook
|
||||||
|
print(f"\nNotebook: {entry.notebook}")
|
||||||
|
print(fmt.format(snippet=result.snippet, name=result.filename.name))
|
||||||
|
|
||||||
|
|
||||||
|
pending_todo = group.add_parser("pending", aliases="p", help="list pending tasks")
|
||||||
|
pending_todo.add_argument("--notebook", "-n", help="limit to this notebook [None]")
|
||||||
|
pending_todo.set_defaults(func=cmd_list_pending)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_mark_complete(
|
||||||
|
config: Config,
|
||||||
|
item: list[str],
|
||||||
|
notebook: str,
|
||||||
|
reverse: bool = False,
|
||||||
|
multiple: bool = False,
|
||||||
|
) -> None:
|
||||||
|
checked, unchecked = "x", " "
|
||||||
|
if reverse:
|
||||||
|
checked, unchecked = unchecked, checked
|
||||||
|
|
||||||
|
item = " ".join(item)
|
||||||
|
results = list(
|
||||||
|
notes.search_contents(
|
||||||
|
config, f"* [{unchecked}] {item}", notebook, full_line=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
raise RuntimeError("No matching item found")
|
||||||
|
|
||||||
|
if not multiple and len(results) > 1:
|
||||||
|
raise RuntimeError("Multiple items matched - use --multiple to allow update")
|
||||||
|
|
||||||
|
update = f"* [{checked}] {item}"
|
||||||
|
for result in results:
|
||||||
|
entry = notes.file_entry(config, result.filename)
|
||||||
|
with open(result.filename, "r") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
lines[result.line_number - 1] = update + "\n"
|
||||||
|
with open(result.filename, "w") as f:
|
||||||
|
f.write("".join(lines))
|
||||||
|
print(
|
||||||
|
f"{entry.notebook:<20s} {entry.filename:30s}[{result.line_number:3d}] {update}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
complete_todo = group.add_parser("mark", aliases=["m"], help="mark a note complete")
|
||||||
|
complete_todo.add_argument("--notebook", "-n", help="add to this notebook [general]")
|
||||||
|
complete_todo.add_argument(
|
||||||
|
"--reverse", "-r", action="store_true", help="uncheck a previously checked item"
|
||||||
|
)
|
||||||
|
complete_todo.add_argument(
|
||||||
|
"--multiple", "-m", action="store_true", help="allow marking all matches"
|
||||||
|
)
|
||||||
|
complete_todo.add_argument("item", help="item to add", nargs="*")
|
||||||
|
complete_todo.set_defaults(func=cmd_mark_complete)
|
||||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[project]
|
||||||
|
name = "pyn"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "PyNotes"
|
||||||
|
authors = [
|
||||||
|
{name = "Tris Forster",email = "tris@tfconsulting.com.au"}
|
||||||
|
]
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
pyn = "pyn.cli:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
Loading…
x
Reference in New Issue
Block a user