Mostly working
This commit is contained in:
commit
5cecc85cf0
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
poetry.lock
|
||||
57
README.md
Normal file
57
README.md
Normal 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
24
pyproject.toml
Normal 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
0
src/pyn/__init__.py
Normal file
62
src/pyn/cli.py
Normal file
62
src/pyn/cli.py
Normal 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
9
src/pyn/config.py
Normal 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
170
src/pyn/crud.py
Normal 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
175
src/pyn/note.py
Normal 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)
|
||||
5
src/pyn/templates/note.md
Normal file
5
src/pyn/templates/note.md
Normal file
@ -0,0 +1,5 @@
|
||||
## {{ timestamp.strftime("%H:%M:%S") }}
|
||||
|
||||
{{ body }}
|
||||
|
||||
---
|
||||
47
src/pyn/templates/render.html
Normal file
47
src/pyn/templates/render.html
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user