Mostly working

This commit is contained in:
Tris Forster 2025-11-05 08:30:00 +11:00
commit 5cecc85cf0
10 changed files with 551 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__
poetry.lock

57
README.md Normal file
View File

@ -0,0 +1,57 @@
# Pyn
Command line interface to opinionated note storage.
## Folder Structure
```
- notes
- home
- 2025-10-10-shopping.md
- 2025-10-13-holiday_plans.md
- 2025-10-14-shopping.md
- 2025-10-19-todo.md
- 2025-10-23-links.md
- projects
- xmon
- 2025-10-08-links.md
- 2025-10-10-design_guide.md
- wso
- admin
- 2025-09-02-minutes.md
- 2025-10-03-minutes.md
- projects
- childrens_concert
- 2025-10-03-budget.md
```
## Examples
```bash
# create a new file in the wso/admin folder and open the editor
pyn add wso/admin minutes
# edit the most recent version
pyn edit wso/admin minutes
# or a specific one - all arguments are fuzzy
pyn edit wso/admin 2025-09 minutes
pyn edit projects/xmon References
# or pyn edit projects/xmon 2025-10-08-references
pyn add home Shopping
pyn edit home Shopping # opens the most recent version
pyn todo add home Do a thing # adds '* Do a thing' after the last list element in /home/2025-19-19-todo.md
pyn todo create home Turn over a new leaf # start a new todo in home
pyn links home https://google.com The eponymous search engine.
pyn cat links # show all the links from every notebook
pyn cat projects links # show all project links - arguments are fuzzy
pyn render wso/projects ch co budget
pyn git push
```

24
pyproject.toml Normal file
View File

@ -0,0 +1,24 @@
[project]
name = "pyn"
version = "0.1.0"
description = ""
authors = [
{name = "Tris Forster",email = "tris@tfconsulting.com.au"}
]
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"click (>=8.3.0,<9.0.0)",
"markdown (>=3.9,<4.0)",
"jinja2 (>=3.1.6,<4.0.0)",
]
[tool.poetry]
packages = [{include = "pyn", from = "src"}]
[project.scripts]
pyn = "pyn.cli:cli"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

0
src/pyn/__init__.py Normal file
View File

62
src/pyn/cli.py Normal file
View File

@ -0,0 +1,62 @@
import click
import logging
from .config import load_config
from .note import PyNote
from . import crud
logger = logging.getLogger(__name__)
@click.group()
@click.option(
"--config",
type=click.Path(exists=False),
envvar="PYN_CONFIG",
default="~/.config/pyn/config.json",
)
@click.option("--logging", "-l", "log_level", default="info")
@click.pass_context
def cli(ctx, config: click.Path, log_level: str):
ctx.obj = PyNote(load_config(config))
logging.basicConfig(level=getattr(logging, log_level.upper(), logging.INFO))
logger.debug(ctx.obj)
cli.add_command(crud.add_note)
cli.add_command(crud.show_note)
cli.add_command(crud.edit_note)
cli.add_command(crud.delete_note)
cli.add_command(crud.cat_note)
cli.add_command(crud.list_notes)
cli.add_command(crud.grep_notes)
cli.add_command(crud.render_note)
@cli.command()
@click.pass_obj
def config(obj):
click.echo(obj.config)
@cli.command()
@click.argument("commands", nargs=-1)
@click.pass_obj
def git(note: PyNote, commands: list[str]):
note.git_cmd(commands)
@cli.command()
@click.pass_obj
def sync(note: PyNote) -> None:
if not note.has_git:
raise click.UsageError("Note repository is not tracked")
note.git_cmd(["pull"])
try:
note.git_cmd(["commit", "-m", "Note sync"])
note.git_cmd(["push"])
except Exception as e:
raise click.UsageError(str(e))

9
src/pyn/config.py Normal file
View File

@ -0,0 +1,9 @@
import json
def load_config(config_path):
try:
with open(config_path, "r") as f:
return json.load(f)
except IOError:
return {}

170
src/pyn/crud.py Normal file
View File

@ -0,0 +1,170 @@
import click
import logging
import subprocess
import sys
import os
import shutil
import markdown
import tempfile
from jinja2 import Environment, PackageLoader, select_autoescape
from datetime import datetime
logger = logging.getLogger(__name__)
TODO_TEMPLATE = """
* {title}
"""
TEMPLATES = {
"todo": TODO_TEMPLATE,
}
env = Environment(loader=PackageLoader("pyn"), autoescape=select_autoescape())
@click.command(name="add")
@click.argument("notebook")
@click.argument("title", nargs=-1)
@click.option("--template", "-t", default="note")
@click.option("--quick", "-q", help="Quick add - don't open editor")
@click.pass_context
def add_note(ctx, notebook: str, title: list[str], template: str, quick: bool) -> None:
"""
Adds a note.
All additional arguments will be taken as the title.
"""
note = ctx.obj
title_str = " ".join(title)
logger.debug("add '%s' [%s]", title_str, notebook)
# template = TEMPLATES.get(template, NOTE_TEMPLATE)
t = env.get_template(f"{template}.md")
if not sys.stdin.isatty(): # we have piped data
body = sys.stdin.read()
else:
body = ""
now = datetime.now()
filename = note.markdown_file(notebook, title_str, create=True)
sys.stderr.write(f"\n{filename}\n")
with open(filename, "a") as f:
f.write(t.render(timestamp=now, title=title_str, body=body))
if not body and not quick:
edit_file(filename)
@click.command(name="cat")
@click.argument("query", nargs=-1)
@click.pass_context
def cat_note(ctx, query: list[str]):
"Alias for pyn show"
ctx.invoke(show_note, query=query)
@click.command(name="show")
@click.argument("query", nargs=-1)
@click.pass_obj
def show_note(note, query: list[str]) -> None:
"""
Display a note in original markdown format
"""
filename = note.select(" ".join(query))
with open(filename, "r") as f:
shutil.copyfileobj(f, sys.stdout)
EDITOR_ARGS = {
"vi": ["-c", ":norm G2k$"],
"vim": ["-c", ":norm G2k$"],
"nvim": ["-c", ":norm G2k$"],
}
def edit_file(filename):
print("EDIT", filename)
editor = os.environ.get("EDITOR", "vi")
cmd = [editor] + EDITOR_ARGS.get(editor, []) + [filename]
subprocess.call(cmd)
@click.command(name="edit")
@click.argument("query", nargs=-1)
@click.pass_obj
def edit_note(note, query: list[str]) -> None:
"""
Open note using your default editor
"""
filename = note.select(" ".join(query))
edit_file(filename)
note.git_cmd(["add", filename])
@click.command(name="delete")
@click.argument("query", nargs=-1)
@click.option("--yes", "-y", is_flag=True, prompt="Are you sure?")
@click.pass_obj
def delete_note(note, query: list[str], yes: bool) -> None:
"""
Delete the given note if it exists
"""
filename = note.select(" ".join(query))
if not yes:
raise click.UsageError("Deletion cancelled")
try:
os.unlink(filename)
click.echo(f"Deleted {filename}")
except FileNotFoundError:
raise click.UsageError(f"{filename} doesn't exist")
@click.command(name="list")
@click.argument("query", nargs=-1)
@click.option("--number", "-n", type=int, default=-1)
@click.pass_obj
def list_notes(note, query: list[str], number: int):
for file in note.find(" ".join(query), ".md"):
click.echo(file)
number -= 1
if number == 0:
return
@click.command(name="grep")
@click.argument("terms", nargs=-1)
@click.pass_obj
def grep_notes(note, terms: list[str]) -> None:
for notebook, filename, lineno, snippet in note.search(terms):
sys.stdout.write(
"{:20s} {:2s}\n{:3d}: {}\n\n".format(notebook, filename, lineno, snippet)
)
@click.command(name="render")
@click.argument("query", nargs=-1)
@click.pass_obj
def render_note(note, query: list[str]):
filename = note.select(" ".join(query), ".md")
md = markdown.Markdown(extensions=["meta"])
with open(filename, "r") as f:
raw = f.read()
content = md.convert(raw)
template = env.get_template("render.html")
print(template.render(content=content, meta=md.Meta))
return
fd, name = tempfile.mkstemp(suffix=".html")
with os.fdopen(fd, "w") as f:
f.write(html)
click.launch(f"file://{name}")

175
src/pyn/note.py Normal file
View File

@ -0,0 +1,175 @@
from datetime import datetime
import pathlib
import re
import os
import logging
import subprocess
import click
import sys
"""
try:
import gnureadline as readline
except:
import readline
"""
logger = logging.getLogger(__name__)
DEFAULTS = {
"note_directory": "~/Documents/notes",
"template": "basic.md",
"filename_format": "{date}-{title}.{ext}",
"filepath_format": "{date.year}/{date.month}/{filename}",
"date_format": "%Y-%m-%d",
}
DEFAULT_TEMPLATE = """---
date: {datetime}
notebook: {notebook}
---
# {title}
"""
def fuzzymatch(query, values):
# tokenise
tokens = re.split(r"\W", query.lower())
logger.debug("Tokens: %r", tokens)
# create a none-greedy matcher
matcher = re.compile(".*".join(tokens))
matches = []
for x in values:
m = matcher.search(x.lower())
if m:
start, end = m.span()
# sort by length ascending, start pos ascending but reverse filename for dates
matches.append((end - start, start, x))
return [x for _, _, x in sorted(matches, reverse=True)]
def slugify(s):
s = s.lower().strip()
s = re.sub(r"[^\w\s-]", "", s)
s = re.sub(r"[\s_-]+", "-", s)
s = re.sub(r"^-+|-+$", "", s)
return s
class PyNote(object):
def __init__(self, config):
self.config = {}
self.config.update(DEFAULTS)
self.config.update(config)
self.directory = pathlib.Path(self.config["note_directory"]).expanduser()
self.directory.mkdir(exist_ok=True)
self._l = len(str(self.directory)) + 1
self.has_git = (self.directory / ".git").exists()
def filename(self, title: str, date=None, ext="md"):
date = date or datetime.now()
date_str = date.strftime(self.config["date_format"])
return self.config["filename_format"].format(
date=date_str, title=slugify(title), ext=ext
)
def filepath(self, filename: str, notebook=None):
fp = self.directory
if notebook:
fp = fp / notebook
fp.mkdir(exist_ok=True)
return fp / filename
def relative_path(self, filename: str) -> str:
return str(filename)[self._l :]
def markdown_file(self, notebook: str, title: str, date=None, create=False):
date = date or datetime.now()
filename = self.filename(title, date)
filepath = self.filepath(filename, notebook)
if not filepath.exists() and create:
with open(filepath, "w") as f:
f.write(
DEFAULT_TEMPLATE.format(
datetime=date.isoformat(timespec="seconds"),
notebook=notebook,
title=title.title(),
)
)
return str(self.directory / filepath)
def files(self, suffix=None):
result = []
c = len(str(self.directory)) + 1
for dirpath, dirnames, filenames in self.directory.walk(top_down=True):
prefix = str(dirpath)[c:]
for d in [x for x in dirnames if x.startswith(".")]:
dirnames.remove(d)
if suffix:
filenames = [x for x in filenames if x.endswith(suffix)]
result.extend([os.path.join(prefix, x) for x in filenames])
return result
def find(self, query: str, suffix=None) -> list[str]:
# print(process.extract(" ".join(query), self.files()))
logger.debug("find: %s [%s]", query, suffix)
result = list(fuzzymatch(query, self.files(suffix)))
return result
def select(self, query: str, suffix=None, entry=None) -> str:
logger.info("Select: %s", query)
options = self.find(query, suffix)
if not options:
raise click.BadParameter(f"No matches for '{query}'")
if len(options) == 1:
entry = 1
else:
sys.stderr.write(f"\nMultiple notes for '{query}'\n\n")
for i, option in enumerate(options):
sys.stderr.write(f"{i + 1:2d}: {option}\n")
selected = input("\nItem: ")
if not selected:
raise click.Abort()
entry = int(selected)
item = options[entry - 1]
sys.stderr.write(f"{item}\n\n")
return str(self.directory / item)
def search(self, terms: list[str]):
cmd = ["grep", "-irn", str(self.directory), "--exclude-dir", ".git"]
for term in terms:
cmd.append("-e")
cmd.append(term)
p = subprocess.run(cmd, cwd=self.directory, capture_output=True, check=True)
for line in p.stdout.decode("utf-8").strip().split("\n"):
file, lineno, snippet = line.split(":")
notebook, _, filename = self.relative_path(file).rpartition("/")
yield notebook, filename, int(lineno), snippet
def git_cmd(self, args: list[str]) -> None:
if not self.has_git:
logger.debug("Skipping git command: %r", args)
return
cmd = ["git", "-C", str(self.directory)]
cmd.extend(args)
logger.info("Executing: %r", cmd)
subprocess.check_call(cmd)

View File

@ -0,0 +1,5 @@
## {{ timestamp.strftime("%H:%M:%S") }}
{{ body }}
---

View File

@ -0,0 +1,47 @@
<!doctype html>
<html>
<head>
<style>
BODY {
font-family: "Nimbus Sans L", Helvetica, Arial, sans-serif;
}
DIV.meta {
display: flex;
justify-content: flex-end;
color: #666;
}
DIV.meta TH {
text-align: right;
}
DIV.page {
}
@media screen {
BODY {
background-color: #EEE;
}
DIV.page {
padding: 3em 5em;
margin: 3em auto;
width: 80%;
background-color: white;
border: 1px solid #DDD;
box-shadow: 2px 2px 2px 1px #AAA;
}
}
</style>
</head>
<body>
<div class="page">{{ content|safe }}</div>
<div class="meta">
<table>
{% for k,v in meta.items() %}
<tr><th>{{k}}:</th><td>{{ v|join(", ") }}</td></tr>
{% endfor %}
</table>
</div>
</body>
</html>