super-quick note taking tool
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

259 lines
9.3 KiB

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Exobrain
.__---~~~(~~-_.
_-' ) -~~- ) _-" )_
( ( `-,_..`.,_--_ '_,)_
( -_) ( -_-~ -_ `, )
(_ -_ _-~-__-~`, ,' )__-'))--___--~~~--__--~~--___--__..
_ ~`_-'( (____;--==,,_))))--___--~~~--__--~~--__----~~~'`=__-~+_-_.
(@)~(@)~~```` `-_(())_-~
"""
import argparse
import os.path
import re
import subprocess
import collections
ENV_VAR_PREFIX = 'EXOBRAIN_'
class Conf(object):
"""
A container object for the application configuration.
Every option can be overridden by an environment variable of the same name,
and can be accessed by using the option name in lower case as an attribute.
The "EXOBRAIN_" prefix may be dropped.
Example: `conf.root` refers to the environment variable "$EXOBRAIN_ROOT" if
defined, and falls back to Conf.EXOBRAIN_ROOT.
"""
EDITOR = "vim"
PAGER = "less -FRX"
EXOBRAIN_INDENT = 4
EXOBRAIN_ROOT = "~/exobrain"
EXOBRAIN_CONF_SEPARATOR = ':'
EXOBRAIN_EXTENSION = ".md"
EXOBRAIN_BULLET = "\u2022"
EXOBRAIN_SMALL_FILE_LINES = 20
EXOBRAIN_DIRNAME_BLACKLIST = ".stversions:.git"
EXOBRAIN_COLORS = """
list=38;5;208
header=38;5;208
code=38;5;111
important=1;38;5;196
info=38;5;95
error=1;31
"""
def __getattribute__(self, attribute):
env_var = attribute.upper()
try:
default = super(Conf, self).__getattribute__(env_var)
except AttributeError:
env_var = ENV_VAR_PREFIX + env_var
default = super(Conf, self).__getattribute__(env_var)
value = type(default)(os.environ.get(env_var, default))
setattr(self, attribute, value)
return value
class Exobrain(object):
def __init__(self):
self.conf = Conf()
self.args = self.parse_args()
self.action = self.args.do
self.note_name = self.args.note_name
self.rootdir = os.path.expanduser(self.args.r).rstrip("/")
self.verbose = self.args.verbose
self.prettify = Prettifier(self.conf)
def parse_args(self):
parser = argparse.ArgumentParser(description='')
action = parser.add_mutually_exclusive_group()
parser.add_argument('note_name', nargs='?', default='default',
metavar="note name")
action.add_argument('-e', help='edit the given note',
action="store_const", const='edit', dest="do")
action.add_argument("-l", "--list", action="store_const", const="list",
dest="do", help="list notes")
parser.add_argument('-r', help='change the root directory',
type=str, metavar='directory',
default=self.conf.root)
parser.add_argument("-t", "--title", action="store_true",
help='select by markdown title')
parser.add_argument("-p", "--pager", action="store_true",
help="pipe note into the pager?")
parser.add_argument("-v", "--verbose", action="store_true",
help="display hidden lines")
return parser.parse_args()
def run(self):
if self.action == 'edit':
filenames = self.find_notes(self.note_name)
if not filenames:
filenames = [os.path.join(self.rootdir,
self.note_name + self.conf.extension)]
self.edit_file(filenames[0])
if len(filenames) > 1:
print(self.prettify.clr("info",
"Note: More than one file found: " + ",".join(filenames)))
elif self.action == "list":
less = subprocess.Popen(self.conf.pager, shell=True, stdin=subprocess.PIPE)
tree = subprocess.Popen(["tree", self.rootdir], stdout=less.stdin)
less.communicate()
elif self.action == None:
filenames = self.find_notes(self.note_name)
if not filenames:
print(self.prettify.clr("error", "Error: no such note"))
elif os.access(filenames[0], os.X_OK):
subprocess.call([filenames[0]])
else:
content = open(filenames[0], 'r').read().rstrip("\n")
if self.args.title:
self.select_by_title(content)
else:
self._render(content.split("\n"))
if len(filenames) > 1:
print(self.prettify.clr("info", "Files: " + ", ".join(
f.replace(self.rootdir + "/", "") for f in filenames[:10])))
def select_by_title(self, content):
if content.count("\n") < self.conf.small_file_lines:
self._render(content.split("\n"))
return
sections = self._extract_sections(content)
if len(sections) < 3:
self._render(content.split("\n"))
return
subsections = sections
while True:
for i, section in enumerate(subsections):
if i == 0:
continue # Don't print "content" every time
print(self.prettify.clr("info", "[%d] %s" % (i, section)))
selection = input("")
if not selection:
selection = 0
try:
key = list(subsections)[int(selection)]
subsections = subsections[key]
except IndexError:
continue
if key == "content":
self._render(subsections)
break
if list(subsections) == ["content"]:
self._render(subsections["content"])
break
@staticmethod
def _extract_sections(markdown):
title_pattern = re.compile(r"^(#+) (.+)$")
sections = collections.OrderedDict(content=[])
path = []
for line in markdown.split("\n"):
match = title_pattern.match(line)
if match:
depth = len(match.group(1))
path = path[:depth-1] + [match.group(2)]
subsection = sections
subsection["content"].append(line)
for x in path:
subsection = subsection.setdefault(x, collections.OrderedDict(content=[]))
subsection["content"].append(line)
return sections
def _render(self, content):
output = "\n".join(self.prettify(content, verbose=self.verbose))
if self.args.pager:
less = subprocess.Popen(self.conf.pager, shell=True, stdin=subprocess.PIPE)
less.communicate(output.encode("utf-8"))
else:
print(output)
@staticmethod
def file_filter(filename):
if os.path.islink(filename):
return os.path.exists(filename)
return True
def find_notes(self, note_name):
complete_matches = []
partial_matches = []
blacklist = self.conf.dirname_blacklist.split(self.conf.conf_separator)
for root, _dirs, files in os.walk(self.rootdir, followlinks=True):
files = [f for f in files if self.file_filter(os.path.join(root, f))]
if any(subdir in blacklist for subdir in root.split(os.path.sep)):
continue
for filename in files:
if note_name in filename:
partial_matches.append(os.path.join(root, filename))
for filename in note_name, note_name + self.conf.extension:
if filename in files:
complete_matches.append(os.path.join(root, filename))
# avoid listing entries twice
for value in list(partial_matches):
if value in complete_matches:
partial_matches.remove(value)
return complete_matches + partial_matches
def edit_file(self, filename):
subprocess.call([self.conf.editor, filename])
class Prettifier(object):
"""
Post-process & colorize notes
"""
def __init__(self, conf):
self.conf = conf
self._parsed_scheme = None
def clr(self, tag, string):
if not self._parsed_scheme:
self._parsed_scheme = self.parse_colorscheme(
self.conf.colors.replace(self.conf.conf_separator, "\n"))
color = self._parsed_scheme.get(tag, "0")
return "\033[%sm%s\033[0m" % (color, string)
@staticmethod
def parse_colorscheme(colorscheme):
scheme = dict()
for line in colorscheme.split("\n"):
if "=" in line:
key, value = line.split('=', 1)
scheme[key.strip()] = value.strip()
return scheme
def __call__(self, lines, verbose=False):
def colorize(color_group):
return lambda match: self.clr(color_group, match.group(1))
def colorize_bullets(match):
return match.group(1) + self.clr('list', self.conf.bullet)
for line in lines:
line, has_bullets = re.subn(r"^(\s*)([*-0])(?= )", colorize_bullets, line)
if not has_bullets:
line, success = re.subn(r"^ ([^-*].*)$", colorize("code"), line)
if has_bullets or not success:
line, success = re.subn(r"^(#+ .*)$", colorize("header"), line)
if not success:
line = re.sub(r"(`[^`].*`)", colorize("code"), line)
line = re.sub(r"(XXX+)", colorize("important"), line)
yield line
if __name__ == '__main__':
Exobrain().run()