Use emacs capture template syntax
This commit is contained in:
parent
a658234cf3
commit
6264de978a
|
@ -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.
|
||||||
|
|
6
TODO.org
6
TODO.org
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in a new issue