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`. No third party dependencies! Just `python 3.6`.
# Running # 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 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. 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] update screens and video now that the notification is nicer
* TODO [#C] how to test it? try on different pages? * 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] 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] 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] do not minfy for dev
* TODO [#D] make manifest templated so we don't have to hardcode actions * 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 * DONE [#B] docs on using
CLOSED: [2019-01-06 Sun 21:39] 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 * DONE [#C] make notification configurable or only in case of errors
CLOSED: [2019-01-13 Sun 13:01] CLOSED: [2019-01-13 Sun 13:01]
* DONE [#C] motivation * DONE [#C] motivation

View file

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

View file

@ -4,3 +4,5 @@ set -eux
pylint -E *.py setup pylint -E *.py setup
mypy --check-untyped-defs *.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 from http.server import BaseHTTPRequestHandler, HTTPServer
import json import json
import os import os
import logging
from pathlib import Path from pathlib import Path
import re 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_PATH_VAR = 'GRASP_CAPTURE_PATH'
CAPTURE_TEMPLATE_VAR = 'GRASP_CAPTURE_TEMPLATE'
def log(*things):
# TODO proper logging def get_logger():
print(*things) 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( def capture(
url, url: str,
title, title,
selection, selection,
comment, comment,
tag_str, tag_str,
): ):
def empty(s) -> bool: logger = get_logger()
return s is None or len(s.strip()) == 0 # protect strings against None
def safe(s: Optional[str]) -> str:
log("capturing", url, title, selection, comment) if s is None:
return ''
else:
return s
capture_path = Path(os.environ[CAPTURE_PATH_VAR]).expanduser() 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] = [] tags: List[str] = []
if not empty(tag_str): if not empty(tag_str):
tags = re.split('[\s,]', tag_str) tags = re.split('[\s,]', tag_str)
tags = [t for t in tags if not empty(t)] # just in case tags = [t for t in tags if not empty(t)] # just in case
if not empty(title): org = as_org(
heading = title url=url,
parts.append(url) title=title,
selection=selection,
# TODO not sure, maybe add as org quote? comment=comment,
if not empty(selection): tags=tags,
parts.extend([ org_template=org_template,
'Selection:', )
selection, # TODO tabulate? append_org(
]) path=capture_path,
if not empty(comment): org=org,
parts.extend([ )
'Comment:',
comment
])
body = None if len(parts) == 0 else '\n'.join(parts)
response = { response = {
'path': str(capture_path), 'path': str(capture_path),
}
# try:
append_org_entry(
capture_path,
heading=heading,
body=body,
tags=tags,
todo=False,
)
response.update({
'status': 'ok', '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') return json.dumps(response).encode('utf8')
class GraspRequestHandler(BaseHTTPRequestHandler): class GraspRequestHandler(BaseHTTPRequestHandler):
def handle_POST(self): def handle_POST(self):
logger = get_logger()
content_length = int(self.headers['Content-Length']) content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length) post_data = self.rfile.read(content_length)
payload = json.loads(post_data.decode('utf8')) payload = json.loads(post_data.decode('utf8'))
log("incoming request:") logger.info("incoming request %s", payload)
log(payload)
res = capture(**payload) res = capture(**payload)
self.send_response(200) self.send_response(200)
self.send_header('Content-Type', 'application/json') self.send_header('Content-Type', 'application/json')
@ -100,30 +102,41 @@ class GraspRequestHandler(BaseHTTPRequestHandler):
self.wfile.write(message.encode('utf8')) self.wfile.write(message.encode('utf8'))
def do_POST(self): def do_POST(self):
logger = get_logger()
try: try:
self.handle_POST() self.handle_POST()
except Exception as e: except Exception as e:
log("Error during processing") logger.error("Error during processing")
log(str(e)) logger.exception(e)
self.respond_error(message=str(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... # not sure if there is a simpler way to communicate with the server...
os.environ[CAPTURE_PATH_VAR] = capture_path os.environ[CAPTURE_PATH_VAR] = capture_path
os.environ[CAPTURE_TEMPLATE_VAR] = template
httpd = HTTPServer(('', int(port)), GraspRequestHandler) httpd = HTTPServer(('', int(port)), GraspRequestHandler)
log(f"Starting httpd on port {port}") logger.info(f"Starting httpd on port {port}")
httpd.serve_forever() httpd.serve_forever()
def setup_parser(p): def setup_parser(p):
p.add_argument('--port', type=str, default='12212', help='Port for communicating with extension') 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('--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(): 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) setup_parser(p)
args = p.parse_args() args = p.parse_args()
run(args.port, args.path) run(args.port, args.path, args.template)
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -1,9 +1,9 @@
from datetime import datetime from datetime import datetime
import logging
from pathlib import Path
import re import re
from typing import List, Optional from typing import List, Optional
DEFAULT_TEMPLATE = "* %U %:description %:tags\n%:link\n%:initial\n"
def date2org(t: datetime) -> str: def date2org(t: datetime) -> str:
return t.strftime("%Y-%m-%d %a") return t.strftime("%Y-%m-%d %a")
@ -13,63 +13,109 @@ def datetime2orgtime(t: datetime) -> str:
def datetime2org(t: datetime) -> str: def datetime2org(t: datetime) -> str:
return date2org(t) + " " + datetime2orgtime(t) return date2org(t) + " " + datetime2orgtime(t)
# TODO priority maybe??
# TODO need to sanitize! def empty(s) -> bool:
def as_org_entry( return s is None or len(s.strip()) == 0
heading: Optional[str] = None,
tags: List[str] = [],
body: Optional[str] = None, def as_org(
created: Optional[datetime]=None, url: str,
todo=True, title: str,
selection: str,
comment: str,
tags: List[str],
org_template: str,
): ):
if heading is None: """
if body is None: 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.
raise RuntimeError('Both heading and body are empty!!')
heading = body.splitlines()[0] # TODO ??
if body is None: Additionally, supports :selection, :comment and :tags expansions.
body = ''
# TODO FIXME escape everything properly! You can look at `test_templates` for some specific examples.
heading = re.sub(r'\s', ' ', heading) """
# TODO remove newlines from body py_template = re.sub(r'%[:]?([?\w]+)', r'{\1}', org_template)
NOW = datetime.now() # TODO tz?? NOW = datetime.now()
# appended only added if it's different from created org_date = date2org(NOW)
app = [f':APPENDED: [{datetime2org(NOW)}]'] if created is not None else [] org_datetime = datetime2org(NOW)
if created is None:
created = NOW
tags_s = '' if len(tags) == 0 else (':' + ':'.join(tags) + ':')
todo_s = ' TODO' if todo else '' # TODO tabulate selection and comments?
tag_s = ':'.join(tags) # 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: initial=initial,
tag_s = f':{tag_s}:' i=initial,
lines = [
f"""*{todo_s} {heading} {tag_s}""", tags=tags_s,
*sch, selection=selection,
':PROPERTIES:', comment=comment,
*app, annotation=f'[[{url}][{title}]]', # https://orgmode.org/manual/capture-protocol.html
f':CREATED: [{datetime2org(created)}]', **{'?': ''}, # just ignore it
':END:', )
body, 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( print()
path: Path, print(res)
*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)

View file

@ -31,7 +31,9 @@ def setup(args):
print(f"Writing systemd config to {out}:") print(f"Writing systemd config to {out}:")
server_bin = Path(__file__).parent.joinpath('grasp_server.py').absolute() 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: with out.open('w') as fo:
fo.write(SYSTEMD_CONFIG.format( fo.write(SYSTEMD_CONFIG.format(
@ -50,7 +52,7 @@ def setup(args):
raise e raise e
def main(): 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') p.add_argument('--unit-name', type=str, default='grasp.service', help='Systemd unit name')
setup_parser(p) setup_parser(p)
args = p.parse_args() args = p.parse_args()