Compare commits

..
5 Commits
Author SHA1 Message Date
tris af14c80803 Added templating for render 2026-07-08 12:25:22 +10:00
tris 44549aaff3 Added basic rendering support and some cleanup 2026-01-29 09:37:15 +11:00
tris e9cc2cc3d0 Minor changes 2025-12-04 09:19:05 +11:00
tris d745444609 Improved error handling 2025-12-03 23:05:19 +11:00
tris 50e9323b4b Bug fixes for mac 2025-12-03 23:00:41 +11:00
8 changed files with 194 additions and 35 deletions
+9
View File
@@ -0,0 +1,9 @@
# PyN
CLI tool for manipulating text notes
### Git support
If the notes directory is under git control then modifications will be auto staged.
You can then execute the `pyn sync` command to pull and push from the upstream server.
+4 -1
View File
@@ -3,6 +3,8 @@ from pyn.config import load_config
from pyn import commands, todo # type: ignore from pyn import commands, todo # type: ignore
import sys
def main(): def main():
options = vars(parser.parse_args()) options = vars(parser.parse_args())
@@ -22,7 +24,8 @@ def main():
try: try:
f(**options) f(**options)
except RuntimeError as e: except RuntimeError as e:
parser.exit(e) sys.stderr.write(f"{e}\n")
parser.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
+64 -7
View File
@@ -1,11 +1,19 @@
from pyn.common import parser, logger from pyn.common import parser, logger
from pyn.config import Config from pyn.config import Config
from pyn import notes from pyn import notes, templating
import sys import sys
import os import os
group = parser.add_subparsers(help="available commands") group = parser.add_subparsers(help="available note commands")
def cmd_config(config):
print(config)
show_config = group.add_parser("config", help="show config")
show_config.set_defaults(func=cmd_config)
### Add a note ### Add a note
@@ -16,11 +24,15 @@ def cmd_add_note(config, title, notebook):
body = "" if sys.stdin.isatty() else sys.stdin.read() body = "" if sys.stdin.isatty() else sys.stdin.read()
notes.append_template(config, filename, "note", title=title, body=body) notes.append_template(filename, "note", title=title, body=body)
if not body: if not body:
notes.edit_file(config, filename) notes.edit_file(config, filename)
notes.run_git(
config, ["add", str(filename.relative_to(config.directory))], quiet=True
)
add_note = group.add_parser("add", aliases=["a"]) add_note = group.add_parser("add", aliases=["a"])
add_note.add_argument("--notebook", "-n", help="add to this notebook [general]") add_note.add_argument("--notebook", "-n", help="add to this notebook [general]")
@@ -45,7 +57,7 @@ def cmd_del_note(config: Config, title: str, notebook: str, yes: bool) -> None:
del_note = group.add_parser("delete", aliases=["del"]) del_note = group.add_parser("delete", aliases=["del"])
del_note.add_argument("--notebook", "-n", help="add to this notebook [general]") 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("--yes", action="store_true", help="dont prompt for confirmation")
del_note.add_argument("title", help="title for note", nargs="?") del_note.add_argument("title", help="title for note", nargs="*")
del_note.set_defaults(func=cmd_del_note) del_note.set_defaults(func=cmd_del_note)
### List notes ### List notes
@@ -55,7 +67,7 @@ def cmd_list_notes(config: Config, notebook: str, pretty: bool) -> None:
for filename in notes.get_file_list(config, notebook): for filename in notes.get_file_list(config, notebook):
if pretty: if pretty:
e = notes.file_entry(config, filename) e = notes.file_entry(config, filename)
print(f"{e.notebook:>20s}: {e.filename}") print(f"\033[32m{e.notebook:>20s}\033[0m: {e.filename}")
else: else:
print(filename) print(filename)
@@ -71,10 +83,13 @@ list_notes.set_defaults(func=cmd_list_notes)
def cmd_edit_note(config, title, notebook: str) -> None: def cmd_edit_note(config, title, notebook: str) -> None:
filename = notes.search_filenames(config, title, notebook) filename = notes.search_filenames(config, title, notebook)
notes.edit_file(config, filename) notes.edit_file(config, filename)
notes.run_git(
config, ["add", str(filename.relative_to(config.directory))], quiet=True
)
edit_note = group.add_parser("edit", aliases=["e"]) edit_note = group.add_parser("edit", aliases=["e"])
edit_note.add_argument("title", help="note title", nargs="?") edit_note.add_argument("title", help="note title", nargs="*")
edit_note.add_argument("--notebook", "-n", help="limit to this notebook") edit_note.add_argument("--notebook", "-n", help="limit to this notebook")
edit_note.set_defaults(func=cmd_edit_note) edit_note.set_defaults(func=cmd_edit_note)
@@ -87,6 +102,48 @@ def cmd_show_note(config, title, notebook: str) -> None:
show_note = group.add_parser("show", aliases=["s", "cat"]) show_note = group.add_parser("show", aliases=["s", "cat"])
show_note.add_argument("title", help="note title", nargs="?") show_note.add_argument("title", help="note title", nargs="*")
show_note.add_argument("--notebook", "-n", help="limit to this notebook") show_note.add_argument("--notebook", "-n", help="limit to this notebook")
show_note.set_defaults(func=cmd_show_note) show_note.set_defaults(func=cmd_show_note)
def cmd_render_note(config, title, notebook: str) -> None:
filename = notes.search_filenames(config, title, notebook)
with open(filename, "r", encoding="utf-8") as input_file:
text = input_file.read()
html = templating.render_note(text, title=filename.stem)
print(html)
render_note = group.add_parser("render")
render_note.add_argument("title", help="note title", nargs="*")
render_note.add_argument("--notebook", "-n", help="limit to this notebook")
render_note.set_defaults(func=cmd_render_note)
### Git
def cmd_git(config: Config, cmd: list[str]) -> None:
output = notes.run_git(config, cmd)
sys.stdout.write(output)
git = group.add_parser("git", help="run a git command in the note directory")
git.add_argument("cmd", help="git command", nargs="*")
git.set_defaults(func=cmd_git)
### Sync
def cmd_sync(config: Config) -> None:
output = notes.run_git(config, ["pull"])
sys.stdout.write(f"Pull: {output}\n")
output = notes.run_git(config, ["push"])
sys.stdout.write(f"Push: {output}\n")
git = group.add_parser("sync", help="synchronise the notes")
git.set_defaults(func=cmd_sync)
+45 -18
View File
@@ -3,11 +3,12 @@ import pathlib
import sys import sys
from pyn.config import Config from pyn.config import Config
from pyn.common import logger
from datetime import datetime from datetime import datetime
from shutil import copyfileobj from shutil import copyfileobj
from collections import namedtuple from collections import namedtuple
from typing import Any from typing import Any, Generator
Entry = namedtuple("Entry", ("notebook", "filename")) Entry = namedtuple("Entry", ("notebook", "filename"))
SearchResult = namedtuple("SearchResult", ("filename", "line_number", "snippet")) SearchResult = namedtuple("SearchResult", ("filename", "line_number", "snippet"))
@@ -15,12 +16,13 @@ SearchResult = namedtuple("SearchResult", ("filename", "line_number", "snippet")
NOTE_HEADING = """--- NOTE_HEADING = """---
date: {date} date: {date}
notebook: {notebook} notebook: {notebook}
---""" ---
"""
TEMPLATES = { TEMPLATES = {
"heading": NOTE_HEADING, "heading": NOTE_HEADING,
"note": "\n\n# {title}\n\n{body}\n---", "note": "\n# {title}\n\n{body}\n---\n",
"todo": "\n* [ ] {item}", "todo": "* [ ] {item}\n",
} }
@@ -44,7 +46,6 @@ def get_note_filename(
notebook_path.mkdir(parents=True, exist_ok=True) notebook_path.mkdir(parents=True, exist_ok=True)
note_path.touch() note_path.touch()
append_template( append_template(
config,
note_path, note_path,
"heading", "heading",
date=now.isoformat(timespec="seconds"), date=now.isoformat(timespec="seconds"),
@@ -54,9 +55,7 @@ def get_note_filename(
return note_path return note_path
def append_template( def append_template(filename: pathlib.Path, template: str, **context: Any) -> None:
config: Config, filename: pathlib.Path, template: str, **context: dict[str, Any]
) -> None:
rendered = TEMPLATES[template].format(**context) rendered = TEMPLATES[template].format(**context)
with open(filename, "a") as f: with open(filename, "a") as f:
f.write(rendered) f.write(rendered)
@@ -64,26 +63,39 @@ def append_template(
def edit_file(config: Config, filename: pathlib.Path) -> None: def edit_file(config: Config, filename: pathlib.Path) -> None:
cmd = config.editor.copy() cmd = config.editor.copy()
cmd.append(filename) cmd.append(str(filename))
subprocess.check_call(cmd) subprocess.check_call(cmd)
def get_file_list(config: Config, notebook: str = None) -> list[pathlib.Path]: def get_file_list(
config: Config, notebook: str | None = None, show_hidden: bool = False
) -> Generator[pathlib.Path]:
root = config.directory / notebook if notebook else config.directory root = config.directory / notebook if notebook else config.directory
for d, _, files in root.walk(): for d, dirs, files in root.walk():
if not show_hidden:
dirs[:] = [d for d in dirs if d[0] != "."]
for filename in files: for filename in files:
yield d / filename if filename.endswith(".md"):
yield d.relative_to(root) / filename
def search_filenames(config: Config, title: str, notebook: str = None) -> pathlib.Path: def search_filenames(
config: Config, title: str | list[str], notebook: str | None = None
) -> pathlib.Path:
root = config.directory / notebook if notebook else config.directory root = config.directory / notebook if notebook else config.directory
files = "\n".join((str(x) for x in get_file_list(config, notebook)))
if isinstance(title, list):
title = " ".join(title)
cmd = ["fzf", "-1", "-i"] cmd = ["fzf", "-1", "-i"]
if title: if title:
cmd.extend(["-q", title]) cmd.extend(["-q", title])
try: try:
output = subprocess.check_output(cmd, cwd=root) output = subprocess.check_output(cmd, cwd=root, input=files.encode())
file_path = root / output.decode("utf-8").strip() file_path = root / output.decode("utf-8").strip()
return file_path return file_path
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
@@ -97,7 +109,7 @@ def file_entry(config: Config, filepath: pathlib.Path) -> Entry:
return Entry(str(relative.parent), relative.name) return Entry(str(relative.parent), relative.name)
def cat_files(*filenames: list[pathlib.Path]) -> None: def cat_files(*filenames: pathlib.Path) -> None:
for filename in filenames: for filename in filenames:
with open(filename, "r") as f: with open(filename, "r") as f:
copyfileobj(f, sys.stdout) copyfileobj(f, sys.stdout)
@@ -106,10 +118,10 @@ def cat_files(*filenames: list[pathlib.Path]) -> None:
def search_contents( def search_contents(
config: Config, config: Config,
query: str, query: str,
notebook: str = None, notebook: str | None = None,
file_matcher: str = None, file_matcher: str = "*.md",
full_line: bool = False, full_line: bool = False,
) -> list[SearchResult]: ) -> Generator[SearchResult]:
root = config.directory / notebook if notebook else config.directory root = config.directory / notebook if notebook else config.directory
cmd = ["grep", "-rFn", query, str(root)] cmd = ["grep", "-rFn", query, str(root)]
@@ -118,6 +130,7 @@ def search_contents(
if full_line: if full_line:
cmd.append("-x") cmd.append("-x")
logger.debug("Executing: %r", cmd)
try: try:
output = subprocess.check_output(cmd) output = subprocess.check_output(cmd)
for line in output.decode("utf-8").strip().split("\n"): for line in output.decode("utf-8").strip().split("\n"):
@@ -127,3 +140,17 @@ def search_contents(
if e.returncode == 1: if e.returncode == 1:
return return
raise raise
def run_git(config: Config, cmd: list[str], quiet: bool = False) -> str:
if not (config.directory / ".git").is_dir():
if quiet:
return ""
raise RuntimeError("Notes not currently tracked")
cmd = ["git", "-C", str(config.directory)] + cmd
try:
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
return output.decode("utf-8").strip()
except subprocess.CalledProcessError as e:
raise RuntimeError(str(e))
+43
View File
@@ -0,0 +1,43 @@
<!doctype html5>
<html>
<head>
<title>{{ title }}</title>
<style>
BODY {
font-family: Arial, Helvetica, sans-serif;
font-size: 10pt;
}
DIV.page {
margin: 20px;
}
HR {
color: #DDD;
}
H1, H2, H3 {
margin-bottom: 5px;
}
P {
margin-top: 2px;
margin-bottom: 2px;
}
H1 {
font-size: 16pt;
}
H2 {
font-size: 14pt;
}
H3 {
font-size: 12pt;
}
DIV.page P:first-of-type {
color: #999;
text-align: right;
}
</style>
</head>
<body>
<div class="page">
{{ content|safe }}
</div>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
from jinja2 import Environment, PackageLoader, select_autoescape
import markdown
env = Environment(loader=PackageLoader("pyn"), autoescape=select_autoescape())
def render_note(note: str, title: str = "") -> str:
content = markdown.markdown(note)
template = env.get_template("render_note.html")
return template.render(content=content, title=title)
+16 -9
View File
@@ -1,9 +1,13 @@
from pyn.common import parser, logger
from pyn.config import Config from pyn.config import Config
from pyn import notes from pyn import notes
from pyn.commands import group as parent_group from pyn.commands import group as parent_group
from shutil import get_terminal_size from shutil import get_terminal_size
import sys
LIST_CHAR = "-"
COMPLETE_CHAR = "x"
PENDING_PATTERN = r"^[+\-\*]\s+\[ \]"
COMPLETED_PATTERN = r"^[+\-\*]\s+\[" + COMPLETE_CHAR + r"\]"
p = parent_group.add_parser("todo", help="manipulate todo items") p = parent_group.add_parser("todo", help="manipulate todo items")
group = p.add_subparsers() group = p.add_subparsers()
@@ -12,9 +16,9 @@ group = p.add_subparsers()
def cmd_add_todo(config: Config, item: list[str], notebook: str, create: bool) -> None: def cmd_add_todo(config: Config, item: list[str], notebook: str, create: bool) -> None:
item = " ".join(item) item_str = " ".join(item)
filename = notes.get_note_filename(config, "todo", notebook) filename = notes.get_note_filename(config, "todo", notebook)
notes.append_template(config, filename, "todo", item=item) notes.append_template(filename, "todo", item=item_str)
add_todo = group.add_parser("add", aliases=["a"]) add_todo = group.add_parser("add", aliases=["a"])
@@ -29,15 +33,18 @@ add_todo.set_defaults(func=cmd_add_todo)
def cmd_list_pending(config: Config, notebook: str): def cmd_list_pending(config: Config, notebook: str):
nb = None nb = None
cols, _ = get_terminal_size() cols, _ = get_terminal_size()
fmt = "{snippet:" + str(cols - 20) + "s} {name}"
for result in notes.search_contents( for result in notes.search_contents(
config, "* [ ]", notebook, file_matcher="*-todo.md" config, "* [ ]", notebook, file_matcher="*-todo.md"
): ):
entry = notes.file_entry(config, result.filename) entry = notes.file_entry(config, result.filename)
if entry.notebook != nb: if entry.notebook != nb:
nb = entry.notebook nb = entry.notebook
print(f"\nNotebook: {entry.notebook}") print(f"\nNotebook: \033[32m{entry.notebook}\033[0m")
print(fmt.format(snippet=result.snippet, name=result.filename.name)) name = result.filename.name
sys.stdout.write(result.snippet)
sys.stdout.write(" " * (cols - len(result.snippet) - len(name) - 1))
sys.stdout.write(name)
sys.stdout.write("\n")
pending_todo = group.add_parser("pending", aliases="p", help="list pending tasks") pending_todo = group.add_parser("pending", aliases="p", help="list pending tasks")
@@ -56,10 +63,10 @@ def cmd_mark_complete(
if reverse: if reverse:
checked, unchecked = unchecked, checked checked, unchecked = unchecked, checked
item = " ".join(item) item_str = " ".join(item)
results = list( results = list(
notes.search_contents( notes.search_contents(
config, f"* [{unchecked}] {item}", notebook, full_line=True config, f"* [{unchecked}] {item_str}", notebook, full_line=True
) )
) )
@@ -69,7 +76,7 @@ def cmd_mark_complete(
if not multiple and len(results) > 1: if not multiple and len(results) > 1:
raise RuntimeError("Multiple items matched - use --multiple to allow update") raise RuntimeError("Multiple items matched - use --multiple to allow update")
update = f"* [{checked}] {item}" update = f"* [{checked}] {item_str}"
for result in results: for result in results:
entry = notes.file_entry(config, result.filename) entry = notes.file_entry(config, result.filename)
with open(result.filename, "r") as f: with open(result.filename, "r") as f:
+2
View File
@@ -8,6 +8,8 @@ authors = [
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"markdown (>=3.10.1,<4.0.0)",
"jinja2 (>=3.1.6,<4.0.0)"
] ]
[project.scripts] [project.scripts]