commit f6397185c469f35fbca2557403547e53d55fb302 Author: Tris Forster Date: Wed Dec 3 21:23:01 2025 +1100 Initial commit 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..e69de29 diff --git a/pyn/__init__.py b/pyn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyn/cli.py b/pyn/cli.py new file mode 100644 index 0000000..09d7f94 --- /dev/null +++ b/pyn/cli.py @@ -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() diff --git a/pyn/commands.py b/pyn/commands.py new file mode 100644 index 0000000..296bce4 --- /dev/null +++ b/pyn/commands.py @@ -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) diff --git a/pyn/common.py b/pyn/common.py new file mode 100644 index 0000000..e79ace8 --- /dev/null +++ b/pyn/common.py @@ -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]" +) diff --git a/pyn/config.py b/pyn/config.py new file mode 100644 index 0000000..fe83d1e --- /dev/null +++ b/pyn/config.py @@ -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 diff --git a/pyn/notes.py b/pyn/notes.py new file mode 100644 index 0000000..4df6f7e --- /dev/null +++ b/pyn/notes.py @@ -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 diff --git a/pyn/todo.py b/pyn/todo.py new file mode 100644 index 0000000..5e09b45 --- /dev/null +++ b/pyn/todo.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bfeadba --- /dev/null +++ b/pyproject.toml @@ -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"