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)