Use emacs capture template syntax

This commit is contained in:
Dima Gerasimov 2019-01-13 15:34:13 +00:00 committed by Dmitrii Gerasimov
parent a658234cf3
commit 6264de978a
7 changed files with 182 additions and 115 deletions

View file

@ -9,9 +9,9 @@ possibly selected text, additional comments or tags and send it into your [Org M
No third party dependencies! Just `python 3.6`.
# Running
1. Install server counterpart as systemd service (to autostart it): `server/setup --path /path/to/your/capture.org [--port <custom port>]`.
1. Install server counterpart as systemd service (to autostart it): `server/setup --path /path/to/your/capture.org [--port <custom port>] [--template <custom org-capture template>]`.
Or alternatively, just run it directly if you don't want to autostart it: `server/grasp_server.py --path /path/to/your/capture.org [--port <custom_port>]`.
Or alternatively, just run it directly if you don't want to autostart it: `server/grasp_server.py --path /path/to/your/capture.org [--port <custom_port>] [--template <custom org-capture template>]`.
2. Install chrome extension and configure hotkeys
That's it! If you're using custom port make sure it's same as in the extension settings.

View file

@ -3,10 +3,12 @@
* TODO [#C] update screens and video now that the notification is nicer
* TODO [#C] how to test it? try on different pages?
https://www.blazemeter.com/blog/6-easy-steps-testing-your-chrome-extension-selenium
looks tricky...
* TODO [#C] some sort of delayed append logic? e.g. don't dump immediately and add as subitems?
* TODO [#C] or, add menu to extension?
* TODO [#C] custom capture template?
* TODO [#C] hmm. if template is specified on client site, it's easier to dispatch todo vs non-todo etc?..
* TODO [#D] do not minfy for dev
* TODO [#D] make manifest templated so we don't have to hardcode actions
@ -40,6 +42,8 @@ Now, the content of `build` folder will be the extension ready to be submitted t
* DONE [#B] docs on using
CLOSED: [2019-01-06 Sun 21:39]
* DONE [#C] custom capture template?
CLOSED: [2019-01-13 Sun 14:57]
* DONE [#C] make notification configurable or only in case of errors
CLOSED: [2019-01-13 Sun 13:01]
* DONE [#C] motivation

View file

@ -1,6 +1,6 @@
{
"name": "grasp-extension",
"version": "0.4.1",
"version": "0.5.0",
"description": "A reliable way of capturing web pages and content",
"scripts": {
"build": "webpack --progress --profile --colors",

View file

@ -4,3 +4,5 @@ set -eux
pylint -E *.py setup
mypy --check-untyped-defs *.py setup
pytest -s org_tools.py

View file

@ -3,90 +3,92 @@ import argparse
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import os
import logging
from pathlib import Path
import re
from typing import List
from typing import List, Optional
from org_tools import append_org_entry
from org_tools import as_org, empty, DEFAULT_TEMPLATE
CAPTURE_PATH_VAR = 'GRASP_CAPTURE_PATH'
CAPTURE_TEMPLATE_VAR = 'GRASP_CAPTURE_TEMPLATE'
def log(*things):
# TODO proper logging
print(*things)
def get_logger():
return logging.getLogger('grasp-server')
def append_org(
path: Path,
org: str
):
logger = get_logger()
# TODO perhaps should be an error?...
if not path.exists():
logger.warning("path %s didn't exist!", path)
# https://stackoverflow.com/a/13232181
if len(org.encode('utf8')) > 4096:
logger.warning("writing out %s might be non-atomic", org)
with path.open('a') as fo:
fo.write(org)
def capture(
url,
url: str,
title,
selection,
comment,
tag_str,
):
def empty(s) -> bool:
return s is None or len(s.strip()) == 0
log("capturing", url, title, selection, comment)
logger = get_logger()
# protect strings against None
def safe(s: Optional[str]) -> str:
if s is None:
return ''
else:
return s
capture_path = Path(os.environ[CAPTURE_PATH_VAR]).expanduser()
org_template = os.environ[CAPTURE_TEMPLATE_VAR]
logger.info('capturing %s to %s', (url, title, selection, comment, tag_str), capture_path)
url = safe(url)
title = safe(title)
selection = safe(selection)
comment = safe(comment)
tag_str = safe(tag_str)
heading = url
parts = []
tags: List[str] = []
if not empty(tag_str):
tags = re.split('[\s,]', tag_str)
tags = [t for t in tags if not empty(t)] # just in case
if not empty(title):
heading = title
parts.append(url)
# TODO not sure, maybe add as org quote?
if not empty(selection):
parts.extend([
'Selection:',
selection, # TODO tabulate?
])
if not empty(comment):
parts.extend([
'Comment:',
comment
])
body = None if len(parts) == 0 else '\n'.join(parts)
org = as_org(
url=url,
title=title,
selection=selection,
comment=comment,
tags=tags,
org_template=org_template,
)
append_org(
path=capture_path,
org=org,
)
response = {
'path': str(capture_path),
}
# try:
append_org_entry(
capture_path,
heading=heading,
body=body,
tags=tags,
todo=False,
)
response.update({
'status': 'ok',
})
# just rely on setting 500 instead
# except Exception as e:
# log(str(e))
# response.update({
# 'status': 'error',
# 'error' : str(e),
# })
}
return json.dumps(response).encode('utf8')
class GraspRequestHandler(BaseHTTPRequestHandler):
def handle_POST(self):
logger = get_logger()
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
payload = json.loads(post_data.decode('utf8'))
log("incoming request:")
log(payload)
logger.info("incoming request %s", payload)
res = capture(**payload)
self.send_response(200)
self.send_header('Content-Type', 'application/json')
@ -100,30 +102,41 @@ class GraspRequestHandler(BaseHTTPRequestHandler):
self.wfile.write(message.encode('utf8'))
def do_POST(self):
logger = get_logger()
try:
self.handle_POST()
except Exception as e:
log("Error during processing")
log(str(e))
logger.error("Error during processing")
logger.exception(e)
self.respond_error(message=str(e))
def run(port: str, capture_path: str):
def run(port: str, capture_path: str, template: str):
logger = get_logger()
if template == repr(DEFAULT_TEMPLATE):
template = DEFAULT_TEMPLATE # hack so argparse renders default template with \n characters...
# not sure if there is a simpler way to communicate with the server...
os.environ[CAPTURE_PATH_VAR] = capture_path
os.environ[CAPTURE_TEMPLATE_VAR] = template
httpd = HTTPServer(('', int(port)), GraspRequestHandler)
log(f"Starting httpd on port {port}")
logger.info(f"Starting httpd on port {port}")
httpd.serve_forever()
def setup_parser(p):
p.add_argument('--port', type=str, default='12212', help='Port for communicating with extension')
p.add_argument('--path', type=str, default='~/capture.org', help='File to capture into')
p.add_argument('--template', type=str, default=repr(DEFAULT_TEMPLATE), help=f"""
{as_org.__doc__}
""")
def main():
p = argparse.ArgumentParser('grasp server setup', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s')
p = argparse.ArgumentParser('grasp server', formatter_class=lambda prog: argparse.ArgumentDefaultsHelpFormatter(prog, width=100)) # type: ignore
setup_parser(p)
args = p.parse_args()
run(args.port, args.path)
run(args.port, args.path, args.template)
if __name__ == '__main__':
main()

View file

@ -1,9 +1,9 @@
from datetime import datetime
import logging
from pathlib import Path
import re
from typing import List, Optional
DEFAULT_TEMPLATE = "* %U %:description %:tags\n%:link\n%:initial\n"
def date2org(t: datetime) -> str:
return t.strftime("%Y-%m-%d %a")
@ -13,63 +13,109 @@ def datetime2orgtime(t: datetime) -> str:
def datetime2org(t: datetime) -> str:
return date2org(t) + " " + datetime2orgtime(t)
# TODO priority maybe??
# TODO need to sanitize!
def as_org_entry(
heading: Optional[str] = None,
tags: List[str] = [],
body: Optional[str] = None,
created: Optional[datetime]=None,
todo=True,
def empty(s) -> bool:
return s is None or len(s.strip()) == 0
def as_org(
url: str,
title: str,
selection: str,
comment: str,
tags: List[str],
org_template: str,
):
if heading is None:
if body is None:
raise RuntimeError('Both heading and body are empty!!')
heading = body.splitlines()[0] # TODO ??
"""
Formats captured results according to org template. Supports all sensible (e.g. non-interactive) template expansions from https://orgmode.org/manual/Template-expansion.html#Template-expansion.
if body is None:
body = ''
Additionally, supports :selection, :comment and :tags expansions.
# TODO FIXME escape everything properly!
heading = re.sub(r'\s', ' ', heading)
# TODO remove newlines from body
You can look at `test_templates` for some specific examples.
"""
py_template = re.sub(r'%[:]?([?\w]+)', r'{\1}', org_template)
NOW = datetime.now() # TODO tz??
# appended only added if it's different from created
app = [f':APPENDED: [{datetime2org(NOW)}]'] if created is not None else []
if created is None:
created = NOW
NOW = datetime.now()
org_date = date2org(NOW)
org_datetime = datetime2org(NOW)
tags_s = '' if len(tags) == 0 else (':' + ':'.join(tags) + ':')
todo_s = ' TODO' if todo else ''
tag_s = ':'.join(tags)
# TODO tabulate selection and comments?
# TODO not sure, maybe add as org quote?
parts = []
if not empty(selection):
parts.extend([
'Selection:',
selection,
])
if not empty(comment):
parts.extend([
'Comment:',
comment
])
initial = '\n'.join(parts)
sch = [f' SCHEDULED: <{date2org(NOW)}>'] if todo else []
res = py_template.format(
t=f'<{org_date}>',
u=f'[{org_date}]',
T=f'<{org_datetime}>',
U=f'[{org_datetime}]',
description=title,
link=url,
if len(tag_s) != 0:
tag_s = f':{tag_s}:'
lines = [
f"""*{todo_s} {heading} {tag_s}""",
*sch,
':PROPERTIES:',
*app,
f':CREATED: [{datetime2org(created)}]',
':END:',
body,
"",
"",
initial=initial,
i=initial,
tags=tags_s,
selection=selection,
comment=comment,
annotation=f'[[{url}][{title}]]', # https://orgmode.org/manual/capture-protocol.html
**{'?': ''}, # just ignore it
)
return res
# TODO escaping?
# just checks it's not crashing for now
def test_templates():
url = 'https://whatever'
title = 'hello'
selection = """some
selected
text
"""
comment = 'fafewfewf'
tags = ['aba', 'caba']
# https://orgmode.org/guide/Capture-templates.html
org_templates = [
DEFAULT_TEMPLATE,
"* %? [[%:link][%:description]] \nCaptured On: %U",
"* %:annotation",
"* %U %:description :protocol: \n %:link \n%:initial \n\n",
"* %t captured stuff %:tags \n ",
"\n** Selection\n%:selection\n** Comment\n%:comment\n",
"""* TODO [#A] %:link
** Selection
%:selection
** Comment
%:comment
""",
"""* %T %:link %:description
#+BEGIN_QUOTE
%:selection
#+END_QUOTE
""",
]
return '\n'.join(lines)
for org_template in org_templates:
res = as_org(
url,
title,
selection,
comment,
tags,
org_template=org_template
# TODO should we check if it exists first?
def append_org_entry(
path: Path,
*args,
**kwargs,
):
res = as_org_entry(*args, **kwargs)
# https://stackoverflow.com/a/13232181
if len(res.encode('utf8')) > 4096:
logging.warning("writing out %s might be non-atomic", res)
with path.open('a') as fo:
fo.write(res)
)
print()
print(res)

View file

@ -31,7 +31,9 @@ def setup(args):
print(f"Writing systemd config to {out}:")
server_bin = Path(__file__).parent.joinpath('grasp_server.py').absolute()
extra_args = f'--port {args.port} --path {args.path}'
template = args.template.replace('%', '%%') # escape for systemd...
extra_args = f'--port {args.port} --path {args.path} --template \"{template}\"'
with out.open('w') as fo:
fo.write(SYSTEMD_CONFIG.format(
@ -50,7 +52,7 @@ def setup(args):
raise e
def main():
p = argparse.ArgumentParser('grasp server setup', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
p = argparse.ArgumentParser('grasp server setup', formatter_class=lambda prog: argparse.ArgumentDefaultsHelpFormatter(prog, width=100)) # type: ignore
p.add_argument('--unit-name', type=str, default='grasp.service', help='Systemd unit name')
setup_parser(p)
args = p.parse_args()