commit 5cecc85cf0a4a6b7330e6be579b3877975edff24 Author: Tris Forster Date: Wed Nov 5 08:30:00 2025 +1100 Mostly working diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ee7f5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +poetry.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..241f4ca --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7ead425 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/pyn/__init__.py b/src/pyn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pyn/cli.py b/src/pyn/cli.py new file mode 100644 index 0000000..2698a96 --- /dev/null +++ b/src/pyn/cli.py @@ -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)) diff --git a/src/pyn/config.py b/src/pyn/config.py new file mode 100644 index 0000000..3d44e47 --- /dev/null +++ b/src/pyn/config.py @@ -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 {} diff --git a/src/pyn/crud.py b/src/pyn/crud.py new file mode 100644 index 0000000..1a58549 --- /dev/null +++ b/src/pyn/crud.py @@ -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}") diff --git a/src/pyn/note.py b/src/pyn/note.py new file mode 100644 index 0000000..812b8a2 --- /dev/null +++ b/src/pyn/note.py @@ -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) diff --git a/src/pyn/templates/note.md b/src/pyn/templates/note.md new file mode 100644 index 0000000..2752b95 --- /dev/null +++ b/src/pyn/templates/note.md @@ -0,0 +1,5 @@ +## {{ timestamp.strftime("%H:%M:%S") }} + +{{ body }} + +--- diff --git a/src/pyn/templates/render.html b/src/pyn/templates/render.html new file mode 100644 index 0000000..10a6eb6 --- /dev/null +++ b/src/pyn/templates/render.html @@ -0,0 +1,47 @@ + + + + + + +
{{ content|safe }}
+
+ + {% for k,v in meta.items() %} + + {% endfor %} +
{{k}}:{{ v|join(", ") }}
+
+ +