176 lines
5.1 KiB
Python
176 lines
5.1 KiB
Python
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)
|