Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
e4830b41be
66
.ci/release
Executable file
66
.ci/release
Executable file
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env python3
|
||||
'''
|
||||
Run [[file:scripts/release][scripts/release]] to deploy Python package onto [[https://pypi.org][PyPi]] and [[https://test.pypi.org][test PyPi]].
|
||||
|
||||
The script expects =TWINE_PASSWORD= environment variable to contain the [[https://pypi.org/help/#apitoken][PyPi token]] (not the password!).
|
||||
|
||||
The script can be run manually.
|
||||
It's also running as =pypi= job in [[file:.github/workflows/main.yml][Github Actions config]]. Packages are deployed on:
|
||||
- every master commit, onto test pypi
|
||||
- every new tag, onto production pypi
|
||||
|
||||
You'll need to set =TWINE_PASSWORD= and =TWINE_PASSWORD_TEST= in [[https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets#creating-encrypted-secrets][secrets]]
|
||||
for Github Actions deployment to work.
|
||||
'''
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from subprocess import check_call
|
||||
import shutil
|
||||
|
||||
is_ci = os.environ.get('CI') is not None
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--test', action='store_true', help='use test pypi')
|
||||
args = p.parse_args()
|
||||
|
||||
extra = []
|
||||
if args.test:
|
||||
extra.extend(['--repository-url', 'https://test.pypi.org/legacy/'])
|
||||
|
||||
root = Path(__file__).absolute().parent.parent
|
||||
os.chdir(root) # just in case
|
||||
|
||||
if is_ci:
|
||||
# see https://github.com/actions/checkout/issues/217
|
||||
check_call('git fetch --prune --unshallow'.split())
|
||||
|
||||
dist = root / 'dist'
|
||||
if dist.exists():
|
||||
shutil.rmtree(dist)
|
||||
|
||||
check_call('python3 setup.py sdist bdist_wheel', shell=True)
|
||||
|
||||
TP = 'TWINE_PASSWORD'
|
||||
password = os.environ.get(TP)
|
||||
if password is None:
|
||||
print(f"WARNING: no {TP} passed", file=sys.stderr)
|
||||
import pip_secrets
|
||||
password = pip_secrets.token_test if args.test else pip_secrets.token # meh
|
||||
|
||||
check_call([
|
||||
'python3', '-m', 'twine',
|
||||
'upload', *dist.iterdir(),
|
||||
*extra,
|
||||
], env={
|
||||
'TWINE_USERNAME': '__token__',
|
||||
TP: password,
|
||||
**os.environ,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
40
.ci/run
Executable file
40
.ci/run
Executable file
|
@ -0,0 +1,40 @@
|
|||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
cd .. # git root
|
||||
|
||||
if ! command -v sudo; then
|
||||
# CI or Docker sometimes doesn't have it, so useful to have a dummy
|
||||
function sudo {
|
||||
"$@"
|
||||
}
|
||||
fi
|
||||
|
||||
if [ -n "${CI-}" ]; then
|
||||
# install OS specific stuff here
|
||||
case "$OSTYPE" in
|
||||
darwin*)
|
||||
# macos
|
||||
:
|
||||
;;
|
||||
cygwin* | msys* | win*)
|
||||
# windows
|
||||
:
|
||||
;;
|
||||
*)
|
||||
# must be linux?
|
||||
:
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
|
||||
PY_BIN="python3"
|
||||
# some systems might have python pointing to python3
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
PY_BIN="python"
|
||||
fi
|
||||
|
||||
"$PY_BIN" -m pip install --user tox
|
||||
"$PY_BIN" -m tox
|
|
@ -1,100 +0,0 @@
|
|||
# TODO should I use yarn as in circleci node example??
|
||||
version: 2
|
||||
|
||||
jobs:
|
||||
client_build_and_check:
|
||||
docker:
|
||||
- image: circleci/node:latest
|
||||
steps:
|
||||
- checkout
|
||||
- run: sudo npm install -g npm web-ext
|
||||
|
||||
- restore_cache:
|
||||
keys:
|
||||
- dependency-cache-{{ checksum "package.json" }}
|
||||
# fallback to using the latest cache if no exact match is found
|
||||
- dependency-cache-
|
||||
- run: npm install
|
||||
- save_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
paths:
|
||||
- ./node_modules
|
||||
|
||||
- run: ./build --chrome --lint
|
||||
|
||||
# TODO ugh. is there a way for a job to inherit a cloned repo after npm run build has ran?? I'd like to separate build and check steps
|
||||
# TODO ok, apparently it's a custom executor? https://circleci.com/docs/2.0/workflows/#using-workspaces-to-share-data-among-jobs
|
||||
- run:
|
||||
name: js flow
|
||||
command: npm run flow
|
||||
when: always
|
||||
- run:
|
||||
name: eslint
|
||||
command: npm run eslint
|
||||
when: always
|
||||
|
||||
- run:
|
||||
# TODO need to do some kind of build matrix over [firefox, chrome] x [anyhost, no_anyhost]
|
||||
# it should be somehow achievable via workspaces, but I've spent 10 minutes and couldn't figure it out...
|
||||
name: pack
|
||||
command: |
|
||||
|
||||
for browser in 'chrome'; do
|
||||
zip -r -j "/tmp/grasp.zip" dist/*
|
||||
done
|
||||
|
||||
# TODO ugh, also can't use env variables in artifacts...
|
||||
- store_artifacts:
|
||||
path: "/tmp/grasp.zip"
|
||||
destination: "grasp.zip"
|
||||
|
||||
|
||||
server_build_and_check:
|
||||
docker:
|
||||
- image: circleci/python:latest
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
# TODO preserve apt cache..
|
||||
- run: sudo apt install python3-pip
|
||||
- run: python3 -m pip install --user mypy pylint pytest
|
||||
|
||||
# test only dependencies
|
||||
- run: sudo apt install httpie
|
||||
- run: python3 -m pip install --user selenium pyautogui
|
||||
#
|
||||
|
||||
- run:
|
||||
name: pylint
|
||||
command: ci/pylint
|
||||
when: always
|
||||
|
||||
- run:
|
||||
name: mypy
|
||||
command: ci/mypy
|
||||
when: always
|
||||
|
||||
- run:
|
||||
name: pytest
|
||||
command: ci/pytest
|
||||
when: always
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
|
||||
build_and_test:
|
||||
jobs:
|
||||
- client_build_and_check
|
||||
- server_build_and_check
|
||||
|
||||
|
||||
# # ugh. circleci only got python3.6 on this node package??? and it's unclear how to combine multiple circleci docker configurations...
|
||||
|
||||
# # - store_artifacts:
|
||||
# # path: test-results.xml
|
||||
# # prefix: tests
|
||||
# # - store_artifacts:
|
||||
# # path: coverage
|
||||
# # prefix: coverage
|
||||
# # - store_test_results:
|
||||
# # path: test-results.xml
|
86
.github/workflows/main.yml
vendored
Normal file
86
.github/workflows/main.yml
vendored
Normal file
|
@ -0,0 +1,86 @@
|
|||
# see https://github.com/karlicoss/pymplate for up-to-date reference
|
||||
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: '*'
|
||||
tags: 'v[0-9]+.*' # only trigger on 'release' tags for PyPi
|
||||
# Ideally I would put this in the pypi job... but github syntax doesn't allow for regexes there :shrug:
|
||||
# P.S. fuck made up yaml DSLs.
|
||||
pull_request: # needed to trigger on others' PRs
|
||||
# Note that people who fork it need to go to "Actions" tab on their fork and click "I understand my workflows, go ahead and enable them".
|
||||
workflow_dispatch: # needed to trigger workflows manually
|
||||
# todo cron?
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest, macos-latest] # , windows-latest]
|
||||
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
|
||||
# 3.11 on windows has this bug, lxml setup fails
|
||||
#https://bugs.launchpad.net/lxml/+bug/1977998
|
||||
exclude: [{platform: windows-latest, python-version: '3.11'}]
|
||||
# vvv just an example of excluding stuff from matrix
|
||||
# exclude: [{platform: macos-latest, python-version: '3.6'}]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
# ugh https://github.com/actions/toolkit/blob/main/docs/commands.md#path-manipulation
|
||||
- run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# uncomment for SSH debugging
|
||||
# - uses: mxschmitt/action-tmate@v3
|
||||
|
||||
# explicit bash command is necessary for Windows CI runner, otherwise it thinks it's cmd...
|
||||
- run: bash .ci/run
|
||||
|
||||
- if: matrix.platform == 'ubuntu-latest' # no need to compute coverage for other platforms
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: .coverage.mypy_${{ matrix.platform }}_${{ matrix.python-version }}
|
||||
path: .coverage.mypy/
|
||||
|
||||
###
|
||||
build_extension:
|
||||
env:
|
||||
name: 'grasp'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
- run: extension/.ci/build --lint # debug version
|
||||
- run: extension/.ci/build --lint --release
|
||||
|
||||
# TODO ugh. can't share github actions artifacts publicly...
|
||||
# TODO for fuck's sake... why does it end up named as .zip.zip ????
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: '${{ env.name }}-chrome-debug-latest.zip'
|
||||
path: 'extension/dist/artifacts/chrome/${{ env.name }}_dev_-*.zip'
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
.name : '${{ env.name }}-chrome-release-latest.zip'
|
||||
path: 'extension/dist/artifacts/chrome/${{ env.name }}-*.zip'
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name : '${{ env.name }}-firefox-debug-latest.zip'
|
||||
path: 'extension/dist/artifacts/firefox/${{ env.name }}_dev_-*.zip'
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name : '${{ env.name }}-firefox-release-latest.zip'
|
||||
path: 'extension/dist/artifacts/firefox/${{ env.name }}-*.zip'
|
|
@ -1,5 +1,3 @@
|
|||
Build status: [![CircleCI](https://circleci.com/gh/karlicoss/grasp.svg?style=svg)](https://circleci.com/gh/karlicoss/grasp)
|
||||
|
||||
Grasp is a browser extension for [Chrome](https://chrome.google.com/webstore/detail/org-grasp/ohhbcfjmnbmgkajljopdjcaokbpgbgfa) and [Firefox](https://addons.mozilla.org/en-US/firefox/addon/grasp), which adds a button/keybinding to capture current page title and url,
|
||||
possibly selected text, additional comments or tags and adds it into your [Org Mode](https://orgmode.org/) file.
|
||||
|
||||
|
|
48
TODO.org
48
TODO.org
|
@ -1,27 +1,43 @@
|
|||
* TODO [#A] simple auth for server bit?
|
||||
* TODO [#B] doesn't look like hotkey working unless you manually rebind.. wtf?
|
||||
|
||||
* 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] 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
|
||||
* CNCL not sure, maybe extension press should be default? control it via option?
|
||||
:LOGBOOK:
|
||||
- State "CNCL" from "TODO" [2022-12-29 Thu 17:41]
|
||||
:END:
|
||||
|
||||
* TODO [#D] production build?
|
||||
## Packing
|
||||
After the development of your extension run the command
|
||||
* DONE [#B] doesn't look like hotkey working unless you manually rebind.. wtf?
|
||||
:LOGBOOK:
|
||||
- State "DONE" from "TODO" [2022-12-29 Thu 17:41]
|
||||
:END:
|
||||
|
||||
```
|
||||
$ NODE_ENV=production npm run build
|
||||
```
|
||||
Now, the content of `build` folder will be the extension ready to be submitted to the Chrome Web Store. Just take a look at the [official guide](https://developer.chrome.com/webstore/publish) to more infos about publishing.
|
||||
* DONE [#D] do not minfy for dev
|
||||
:LOGBOOK:
|
||||
- State "DONE" from "TODO" [2022-12-29 Thu 17:40]
|
||||
:END:
|
||||
|
||||
* DONE [#D] make manifest templated so we don't have to hardcode actions
|
||||
:LOGBOOK:
|
||||
- State "DONE" from "TODO" [2022-12-29 Thu 17:40]
|
||||
:END:
|
||||
|
||||
* DONE [#D] production build?
|
||||
:LOGBOOK:
|
||||
- State "DONE" from "TODO" [2022-12-29 Thu 17:40]
|
||||
:END:
|
||||
: ## Packing
|
||||
: After the development of your extension run the command
|
||||
:
|
||||
: ```
|
||||
: $ NODE_ENV=production npm run build
|
||||
: ```
|
||||
: Now, the content of `build` folder will be the extension ready to be submitted to the Chrome Web Store. Just take a look at the [official guide](https://developer.chrome.com/webstore/publish) to more infos about publishing.
|
||||
|
||||
|
||||
* TODO not sure, maybe extension press should be default? control it via option?
|
||||
* DONE [#A] adjust permissions... I guess gonna have to make it include /capture endpoint?
|
||||
CLOSED: [2019-01-06 Sun 23:57]
|
||||
|
||||
|
@ -35,7 +51,11 @@ Now, the content of `build` folder will be the extension ready to be submitted t
|
|||
CLOSED: [2019-01-13 Sun 13:01]
|
||||
* DONE [#B] release somewhere? not sure if necessary
|
||||
CLOSED: [2019-01-07 Mon 23:57]
|
||||
** TODO [#C] post on reddit?
|
||||
** DONE [#C] post on reddit?
|
||||
:LOGBOOK:
|
||||
- State "DONE" from "TODO" [2022-12-29 Thu 17:40]
|
||||
:END:
|
||||
|
||||
* DONE [#B] get rid of kython, run ci for python too?
|
||||
CLOSED: [2019-01-07 Mon 23:57]
|
||||
|
||||
|
|
74
build
74
build
|
@ -1,74 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
from subprocess import check_call
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# TODO FIXME copypaste from promnesia
|
||||
# TODO FIXME use it in ci script
|
||||
TARGETS = ['chrome', 'firefox']
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--release', action='store_true')
|
||||
p.add_argument('--sign', action='store_true')
|
||||
p.add_argument('--lint', action='store_true')
|
||||
|
||||
tg = p.add_mutually_exclusive_group(required=True)
|
||||
tg.add_argument('--target', type=str, choices=TARGETS)
|
||||
for b in TARGETS:
|
||||
tg.add_argument('--' + b, action='store_const', const=b, dest='target')
|
||||
args = p.parse_args()
|
||||
target = args.target
|
||||
|
||||
assert target is not None
|
||||
|
||||
dist_dir = Path(__file__).absolute().parent / 'dist'
|
||||
def webext(*args, **kwargs):
|
||||
src_dir = dist_dir # / target
|
||||
src_dir = src_dir.resolve() # TODO ugh. webext can't into symlinks???
|
||||
check_call([
|
||||
'web-ext',
|
||||
'--source-dir', str(src_dir),
|
||||
*args,
|
||||
], **kwargs)
|
||||
|
||||
env = {
|
||||
'TARGET': target,
|
||||
'RELEASE': 'YES' if args.release else 'NO',
|
||||
**os.environ,
|
||||
}
|
||||
check_call([
|
||||
'npm', 'run', 'build',
|
||||
], env=env, cwd=str(Path(__file__).absolute().parent))
|
||||
|
||||
if args.lint:
|
||||
# TODO --self-hosted
|
||||
# TODO warnings as errors??
|
||||
webext('lint')
|
||||
|
||||
if args.release:
|
||||
assert args.lint # TODO not sure..
|
||||
|
||||
if args.sign:
|
||||
assert args.release
|
||||
if target not in ['firefox', 'firefox-mobile']:
|
||||
raise RuntimeError("{target} is not supported for signing yet".format(target=target))
|
||||
|
||||
dist_dir = Path(__file__).absolute().parent / 'dist'
|
||||
src_dir = dist_dir / target
|
||||
|
||||
from firefox_dev_secrets import API_KEY, API_SECRET
|
||||
webext(
|
||||
'--source-dir' , str(src_dir),
|
||||
'--artifacts-dir', str(dist_dir),
|
||||
'--api-key' , API_KEY,
|
||||
'--api-secret', API_SECRET,
|
||||
'sign',
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
# TODO use UUID? https://addons.mozilla.org/en-GB/developers/addon/grasp/edit
|
||||
# TODO right, wouldn't work because it's listed..
|
8
ci/mypy
8
ci/mypy
|
@ -1,8 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -eux
|
||||
|
||||
# TODO ugh. doesn't work from circleci...
|
||||
# cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# TODO use strict?
|
||||
python3 -m mypy --check-untyped-defs "$@" server/*.py server/setup
|
|
@ -1,7 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -eux
|
||||
|
||||
# TODO ugh. doesn't work from circleci...
|
||||
# cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
python3 -m pylint -E "$@" server/*.py server/setup
|
|
@ -1,7 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -eux
|
||||
|
||||
# cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
python3 -m pytest "$@" server/*.py
|
||||
# TODO force it to collect server/setup
|
22
extension/.ci/build
Executable file
22
extension/.ci/build
Executable file
|
@ -0,0 +1,22 @@
|
|||
#!/bin/bash
|
||||
set -eux
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
cd ../.. # git root
|
||||
|
||||
cd extension
|
||||
|
||||
npm install
|
||||
|
||||
FAILED=0
|
||||
|
||||
npm run flow || FAILED=1
|
||||
npm run eslint || FAILED=1
|
||||
# TODO later
|
||||
#npm run test || FAILED=1
|
||||
|
||||
for browser in 'firefox' 'chrome'; do
|
||||
./build --target "$browser" "$@"
|
||||
done
|
||||
|
||||
exit "$FAILED"
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
"parser": "babel-eslint",
|
||||
"parser": "@babel/eslint-parser",
|
||||
"plugins": [
|
||||
"flowtype"
|
||||
],
|
||||
|
@ -27,12 +27,12 @@ module.exports = {
|
|||
"indent": "off",
|
||||
"comma-dangle": "off",
|
||||
"no-console": "off",
|
||||
"no-inner-declarations": "off",
|
||||
"flowtype/space-before-type-colon": "off",
|
||||
"no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}],
|
||||
}
|
||||
// TODO use flow?
|
||||
};
|
||||
//module.exports = {
|
||||
// // "extends": "google",
|
||||
// 'extends': [
|
||||
// "plugin:react/recommended",
|
||||
// ],
|
||||
//};
|
|
@ -2,6 +2,7 @@
|
|||
.*/node_modules/.*
|
||||
|
||||
[include]
|
||||
src/
|
||||
|
||||
[libs]
|
||||
node_modules/flow-interfaces-chrome/interfaces/
|
10
extension/babel.config.js
Normal file
10
extension/babel.config.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
const presets = [
|
||||
'@babel/preset-flow',
|
||||
]
|
||||
const plugins = []
|
||||
|
||||
// if (process.env["ENV"] === "prod") {
|
||||
// plugins.push(...);
|
||||
// }
|
||||
|
||||
module.exports = { presets, plugins }
|
117
extension/build
Executable file
117
extension/build
Executable file
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
from subprocess import check_call
|
||||
from pathlib import Path
|
||||
|
||||
# right, specifying id in manifest doesn't seem to work
|
||||
# AMO responds with: Server response: Duplicate add-on ID found. (status: 400)
|
||||
IDS = {
|
||||
'firefox' : '{37e42980-a7c9-473c-96d5-13f18e0efc74}',
|
||||
'chrome' : 'ohhbcfjmnbmgkajljopdjcaokbpgbgfa',
|
||||
}
|
||||
|
||||
|
||||
TARGETS = [
|
||||
'chrome',
|
||||
'firefox',
|
||||
]
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--release', action='store_true', help="Use release flavor of build")
|
||||
p.add_argument('--watch' , action='store_true')
|
||||
p.add_argument('--lint' , action='store_true')
|
||||
p.add_argument('--publish', action='store_true', help="Publish on chrome web store/addons.mozilla.org")
|
||||
|
||||
tg = p.add_mutually_exclusive_group(required=True)
|
||||
tg.add_argument('--target', type=str, choices=TARGETS)
|
||||
for b in TARGETS:
|
||||
tg.add_argument('--' + b, action='store_const', const=b, dest='target')
|
||||
args = p.parse_args()
|
||||
target = args.target
|
||||
|
||||
assert target is not None
|
||||
|
||||
base_ext_dir = Path(__file__).absolute().parent / 'dist'
|
||||
ext_dir = (base_ext_dir / target).resolve() # webext can't into symlinks
|
||||
# sadly no way to specify zip name in the regex..
|
||||
artifacts_dir = (base_ext_dir / 'artifacts' / target).resolve()
|
||||
def webext(*args, **kwargs):
|
||||
check_call([
|
||||
'npm', 'run', 'web-ext',
|
||||
'--',
|
||||
'--source-dir' , ext_dir,
|
||||
'--artifacts-dir', artifacts_dir,
|
||||
*args,
|
||||
], **kwargs)
|
||||
|
||||
env = {
|
||||
'TARGET' : target,
|
||||
'RELEASE': 'YES' if args.release else 'NO',
|
||||
'PUBLISH': 'YES' if args.publish else 'NO',
|
||||
'EXT_ID' : IDS[target],
|
||||
**os.environ,
|
||||
}
|
||||
|
||||
if args.watch:
|
||||
check_call([
|
||||
'npm', 'run', 'watch',
|
||||
], env=env, cwd=str(Path(__file__).absolute().parent))
|
||||
# TODO exec instead?
|
||||
return
|
||||
|
||||
check_call([
|
||||
'npm', 'run', 'build',
|
||||
], env=env, cwd=str(Path(__file__).absolute().parent))
|
||||
|
||||
if args.lint:
|
||||
# TODO --self-hosted
|
||||
# TODO warnings as errors??
|
||||
webext('lint')
|
||||
|
||||
# TODO move somewhere more appropriate..
|
||||
webext('build', '-o') # -o overwrites existing artifact
|
||||
|
||||
|
||||
if args.release:
|
||||
assert args.lint # TODO not sure..
|
||||
|
||||
def firefox_release_args():
|
||||
from firefox_dev_secrets import API_KEY, API_SECRET
|
||||
return [
|
||||
'--artifacts-dir', str(artifacts_dir),
|
||||
'--api-key' , API_KEY,
|
||||
'--api-secret' , API_SECRET,
|
||||
'--id' , IDS[target],
|
||||
]
|
||||
|
||||
if args.publish:
|
||||
assert args.release
|
||||
if 'firefox' in target:
|
||||
check_call([
|
||||
'npm', 'run', 'release:amo',
|
||||
'--',
|
||||
'--channel', 'listed',
|
||||
'--source-dir', str(ext_dir),
|
||||
*firefox_release_args(),
|
||||
])
|
||||
elif target == 'chrome':
|
||||
from chrome_dev_secrets import CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN
|
||||
check_call([
|
||||
'npm', 'run', 'release:cws',
|
||||
'--',
|
||||
'upload',
|
||||
# '--auto-publish',
|
||||
'--source' , str(ext_dir),
|
||||
'--client-id' , CLIENT_ID,
|
||||
'--client-secret' , CLIENT_SECRET,
|
||||
'--refresh--token', REFRESH_TOKEN,
|
||||
'--extension-id' , IDS[target],
|
||||
# TODO trusted testers?
|
||||
])
|
||||
else:
|
||||
raise RuntimeError("{target} is not supported for publishing yet".format(target=target))
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
101
extension/flow-typed/webextension-polyfill.js
vendored
Normal file
101
extension/flow-typed/webextension-polyfill.js
vendored
Normal file
|
@ -0,0 +1,101 @@
|
|||
/* @flow */
|
||||
|
||||
/*
|
||||
* Based on https://github.com/Shraymonks/flow-interfaces-chrome
|
||||
* not sure if browser polyfill has flow types??
|
||||
* https://github.com/mozilla/webextension-polyfill/issues/421
|
||||
*/
|
||||
|
||||
|
||||
type browser$StorageArea = {
|
||||
get: (keys: string | Array<string> | Object | null) => Promise<Object>,
|
||||
set: (items: Object) => Promise<void>,
|
||||
}
|
||||
|
||||
|
||||
type browser$storage = {
|
||||
local: browser$StorageArea,
|
||||
managed: browser$StorageArea,
|
||||
sync: browser$StorageArea,
|
||||
}
|
||||
|
||||
|
||||
type result = boolean
|
||||
type granted = boolean
|
||||
|
||||
|
||||
type browser$permissions = {
|
||||
contains: (permissions: chrome$Permissions) => Promise<result>,
|
||||
request : (permissions: chrome$Permissions) => Promise<granted>,
|
||||
}
|
||||
|
||||
|
||||
type browser$tabs = {
|
||||
query(queryInfo: {
|
||||
active?: boolean,
|
||||
audible?: boolean,
|
||||
currentWindow?: boolean,
|
||||
highlighted?: boolean,
|
||||
index?: number,
|
||||
lastFocusedWindow?: boolean,
|
||||
muted?: boolean,
|
||||
pinned?: boolean,
|
||||
status?: chrome$TabStatus,
|
||||
title?: string,
|
||||
url?: string | Array<string>,
|
||||
windowId?: number,
|
||||
windowType?: chrome$WindowType
|
||||
}): Promise<Array<chrome$Tab>>,
|
||||
executeScript: (
|
||||
((
|
||||
tabId?: number,
|
||||
details: {
|
||||
allFrames?: boolean,
|
||||
code?: string,
|
||||
file?: string,
|
||||
frameId?: number,
|
||||
matchAboutBlank?: boolean,
|
||||
runAt?: chrome$RunAt
|
||||
},
|
||||
) => Promise<void>) &
|
||||
((
|
||||
details: {
|
||||
allFrames?: boolean,
|
||||
code?: string,
|
||||
file?: string,
|
||||
frameId?: number,
|
||||
matchAboutBlank?: boolean,
|
||||
runAt?: chrome$RunAt
|
||||
},
|
||||
) => Promise<void>)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
type ExecuteResult = {
|
||||
result: any,
|
||||
}
|
||||
|
||||
|
||||
type browser$scripting = {
|
||||
executeScript: ({
|
||||
target: {tabId?: number},
|
||||
func: () => void,
|
||||
}) => Promise<Array<ExecuteResult>>,
|
||||
}
|
||||
|
||||
|
||||
declare var browser: {
|
||||
storage: browser$storage,
|
||||
permissions: browser$permissions,
|
||||
tabs: browser$tabs,
|
||||
scripting: browser$scripting,
|
||||
}
|
||||
|
||||
|
||||
declare module "webextension-polyfill" {
|
||||
declare var storage: browser$storage;
|
||||
declare var permissions: browser$permissions;
|
||||
declare var tabs: browser$tabs;
|
||||
declare var scripting: browser$scripting;
|
||||
}
|
188
extension/jest.config.js
Normal file
188
extension/jest.config.js
Normal file
|
@ -0,0 +1,188 @@
|
|||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// Respect "browser" field in package.json when resolving modules
|
||||
// browser: false,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: null,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
// coverageDirectory: null,
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: null,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: null,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: null,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: null,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: null,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: null,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: null,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: null,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "jsdom",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
// testMatch: [
|
||||
// "**/__tests__/**/*.[jt]s?(x)",
|
||||
// "**/?(*.)+(spec|test).[tj]s?(x)"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: null,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: null,
|
||||
|
||||
// ??? https://github.com/facebook/jest/issues/9395#issuecomment-665239070
|
||||
// otherwise fails on importing
|
||||
transformIgnorePatterns: ['node_modules/?!@webext-options-sync/']
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: null,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
57
extension/package.json
Normal file
57
extension/package.json
Normal file
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "grasp",
|
||||
"version": "0.7.1",
|
||||
"version_name": "released on 2022.12.28",
|
||||
"description": "Reliably capture links and webpage content in a plaintext file (org-mode/markdown)",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"build": "webpack --progress --profile",
|
||||
"watch": "webpack --watch --progress",
|
||||
"dev": "npm run watch",
|
||||
"eslint": "eslint src",
|
||||
"flow": "flow",
|
||||
"web-ext": "web-ext",
|
||||
"release:cws": "chrome-webstore-upload",
|
||||
"release:amo": "web-ext-submit"
|
||||
},
|
||||
"browserslist": ["defaults and supports es6-module"],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/karlicoss/grasp.git"
|
||||
},
|
||||
"author": "Dmitrii Gerasimov <karlicoss@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/karlicoss/grasp/issues"
|
||||
},
|
||||
"homepage": "https://github.com/karlicoss/grasp#readme",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.0",
|
||||
"@babel/eslint-parser": "^7.16.3",
|
||||
"@babel/preset-env": "^7.16.4",
|
||||
"@babel/preset-flow": "^7.16.0",
|
||||
"babel-loader": "^9.1.0",
|
||||
"chrome-webstore-upload-cli": "^2.1.0",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.5.1",
|
||||
"eslint": "^8.15.0",
|
||||
"eslint-plugin-flowtype": "^8.0.3",
|
||||
"flow-bin": "^0.196.3",
|
||||
"flow-interfaces-chrome": "^0.7.0",
|
||||
"jest": "^28.1.0",
|
||||
"jest-environment-jsdom": "^28.1.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"node-fetch": "^2.6.1",
|
||||
"style-loader": "^3.3.1",
|
||||
"web-ext": "^7.4.0",
|
||||
"web-ext-submit": "^7.4.0",
|
||||
"webextension-polyfill": "^0.10.0",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^5.0.1",
|
||||
"webpack-dev-server": "^4.11.1",
|
||||
"webpack-extension-manifest-plugin": "^0.8.0"
|
||||
},
|
||||
"private": "true"
|
||||
}
|
BIN
extension/src/img/unicorn.png
Normal file
BIN
extension/src/img/unicorn.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
162
extension/src/js/background.js
Normal file
162
extension/src/js/background.js
Normal file
|
@ -0,0 +1,162 @@
|
|||
/* @flow */
|
||||
|
||||
|
||||
// hmm this works if you declare type: module in manifest
|
||||
// but it injects the script's contents statically?
|
||||
// I assume this is only processed with webpack
|
||||
// import * as browser from "./browser-polyfill"
|
||||
|
||||
// this works with v3 but NOT if type: module
|
||||
// also unclear how to make it work with webpack??
|
||||
// const manifestVersion = chrome.runtime.getManifest().manifest_version
|
||||
// if (manifestVersion == 3) {
|
||||
// // for v3 it's provided via manifest
|
||||
// try {
|
||||
// /* eslint-disable no-undef */
|
||||
// importScripts('./browser-polyfill.js')
|
||||
// } catch (e) {
|
||||
// console.error(e)
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
// this works for v2 both in chrome and firefox
|
||||
// for v3
|
||||
// - in chrome it works if type: module, but somehow NOT if we define chunks??
|
||||
// without type: module this isn't working
|
||||
// - in firefox it just works :shrug:
|
||||
// but firefox foesn't allow type: module??
|
||||
// could it be bug in chrome??
|
||||
import * as browser from "webextension-polyfill"
|
||||
|
||||
|
||||
import {COMMAND_CAPTURE_SIMPLE, METHOD_CAPTURE_WITH_EXTRAS, showNotification} from './common'
|
||||
import {getOptions} from './options'
|
||||
import type {Options} from './options'
|
||||
|
||||
|
||||
type Params = {
|
||||
url: string,
|
||||
title: ?string,
|
||||
selection: ?string,
|
||||
comment: ?string,
|
||||
tag_str: ?string,
|
||||
}
|
||||
|
||||
|
||||
async function makeCaptureRequest(
|
||||
params: Params,
|
||||
options: Options,
|
||||
) {
|
||||
if (params.tag_str == null) {
|
||||
params.tag_str = options.default_tags
|
||||
}
|
||||
|
||||
const endpoint = options.endpoint
|
||||
|
||||
const data = JSON.stringify(params)
|
||||
console.log(`capturing ${data} via ${endpoint}`)
|
||||
|
||||
|
||||
// TODO maybe handle catch first and then again?
|
||||
|
||||
await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: data,
|
||||
}).catch((err: Error) => {
|
||||
// fetch only rejects when host is unavailable
|
||||
console.error(err)
|
||||
throw new Error(`${endpoint} unavailable, check if server is running`)
|
||||
}).then(async response => {
|
||||
if (!response.ok) { // http error
|
||||
throw new Error(`${endpoint}: HTTP ${response.status} ${response.statusText}`)
|
||||
}
|
||||
const jres = await response.json()
|
||||
const path = jres.path
|
||||
console.log(`success: ${jres}`);
|
||||
if (options.notification) {
|
||||
showNotification(`OK: captured to ${path}`);
|
||||
}
|
||||
}).catch((err: Error) => {
|
||||
console.error(err)
|
||||
// todo make sure we catch errors from then clause too?
|
||||
const error_message = `ERROR: ${err.toString()}`
|
||||
console.error(error_message);
|
||||
showNotification(error_message, 1);
|
||||
// TODO crap, doesn't really seem to respect urgency...
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// TODO ugh. need defensive error handling on the very top...
|
||||
async function capture(comment: ?string = null, tag_str: ?string = null) {
|
||||
const tabs = await browser.tabs.query({currentWindow: true, active: true})
|
||||
const tab = tabs[0]
|
||||
if (tab.url == null) {
|
||||
// todo await?
|
||||
showNotification('ERROR: trying to capture null')
|
||||
return
|
||||
}
|
||||
const url: string = tab.url
|
||||
const title: ?string = tab.title
|
||||
|
||||
|
||||
const payload = (selection: ?string) => {
|
||||
return {
|
||||
url: url,
|
||||
title: title,
|
||||
selection: selection,
|
||||
comment: comment,
|
||||
tag_str: tag_str,
|
||||
}
|
||||
}
|
||||
|
||||
const opts = await getOptions()
|
||||
|
||||
const has_scripting = 'scripting' in chrome
|
||||
let selection
|
||||
if (has_scripting) {
|
||||
// TODO switch to polyfill and add flow types
|
||||
// scripting is already promise based so it should be oly change to types
|
||||
const results = await browser.scripting.executeScript({
|
||||
target: {tabId: tab.id},
|
||||
func: () => window.getSelection().toString()
|
||||
})
|
||||
const [res] = results // should only inject in one frame, so just one result
|
||||
selection = res.result
|
||||
} else {
|
||||
const selections = await browser.tabs.executeScript({
|
||||
code: "window.getSelection().toString();"
|
||||
})
|
||||
selection = selections == null ? null : selections[0]
|
||||
}
|
||||
await makeCaptureRequest(payload(selection), opts)
|
||||
}
|
||||
|
||||
|
||||
chrome.commands.onCommand.addListener(command => {
|
||||
if (command === COMMAND_CAPTURE_SIMPLE) {
|
||||
// todo await
|
||||
capture(null, null)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
chrome.runtime.onMessage.addListener((message: any, sender: chrome$MessageSender, sendResponse) => {
|
||||
if (message.method === 'logging') {
|
||||
console.error("[%s] %o", message.source, message.data)
|
||||
}
|
||||
if (message.method === METHOD_CAPTURE_WITH_EXTRAS) {
|
||||
const comment = message.comment;
|
||||
const tag_str = message.tag_str;
|
||||
// todo await
|
||||
capture(comment, tag_str)
|
||||
sendResponse()
|
||||
}
|
||||
})
|
||||
|
||||
// TODO handle cannot access chrome:// url??
|
||||
|
|
@ -9,13 +9,14 @@ export function showNotification(text: string, priority: number=0) {
|
|||
'title': "grasp",
|
||||
'message': text,
|
||||
'priority': priority,
|
||||
'iconUrl': 'unicorn.png',
|
||||
'iconUrl': 'img/unicorn.png',
|
||||
});
|
||||
}
|
||||
|
||||
// $FlowFixMe
|
||||
export function awrap(fn, ...args: Array<any>): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// $FlowFixMe
|
||||
const cbb = (...xxx) => {
|
||||
const err = chrome.runtime.lastError;
|
||||
if (err) {
|
30
extension/src/js/options.js
Normal file
30
extension/src/js/options.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
/* @flow */
|
||||
import browser from "webextension-polyfill"
|
||||
|
||||
|
||||
export type Options = {
|
||||
endpoint: string;
|
||||
default_tags: string;
|
||||
notification: boolean;
|
||||
}
|
||||
|
||||
|
||||
function defaultOptions(): Options {
|
||||
return {
|
||||
endpoint: "http://localhost:12212/capture",
|
||||
default_tags: "grasp",
|
||||
notification: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getOptions(): Promise<Options> {
|
||||
const res = await browser.storage.local.get(null)
|
||||
return {...defaultOptions(), ...res}
|
||||
}
|
||||
|
||||
|
||||
export async function setOptions(opts: Options): Promise<void> {
|
||||
console.log('Saving %o', opts)
|
||||
await browser.storage.local.set(opts)
|
||||
}
|
84
extension/src/js/options_page.js
Normal file
84
extension/src/js/options_page.js
Normal file
|
@ -0,0 +1,84 @@
|
|||
/* @flow */
|
||||
import {getOptions, setOptions} from './options'
|
||||
import {ensurePermissions, hasPermissions} from './permissions'
|
||||
|
||||
|
||||
const ENDPOINT_ID = 'endpoint_id';
|
||||
const HAS_PERMISSION_ID = 'has_permission_id';
|
||||
const NOTIFICATION_ID = 'notification_id';
|
||||
const DEFAULT_TAGS_ID = 'default_tags_id';
|
||||
const SAVE_ID = 'save_id';
|
||||
|
||||
|
||||
function getEndpoint(): HTMLInputElement {
|
||||
return ((document.getElementById(ENDPOINT_ID): any): HTMLInputElement);
|
||||
}
|
||||
|
||||
function getDefaultTags(): HTMLInputElement {
|
||||
return ((document.getElementById(DEFAULT_TAGS_ID): any): HTMLInputElement);
|
||||
// TODO if empty, return null?
|
||||
}
|
||||
|
||||
function getEnableNotification(): HTMLInputElement {
|
||||
return ((document.getElementById(NOTIFICATION_ID): any): HTMLInputElement);
|
||||
}
|
||||
|
||||
function getSaveButton(): HTMLInputElement {
|
||||
return ((document.getElementById(SAVE_ID): any): HTMLInputElement);
|
||||
}
|
||||
|
||||
function getHasPermission(): HTMLElement {
|
||||
return ((document.getElementById(HAS_PERMISSION_ID): any): HTMLElement);
|
||||
}
|
||||
|
||||
|
||||
// ugh. needs a better name..
|
||||
async function refreshPermissionValidation(endpoint: string) {
|
||||
const has = await hasPermissions(endpoint)
|
||||
const pstyle = getHasPermission().style
|
||||
if (has) {
|
||||
console.debug('Got permisssions, nothing to worry about.')
|
||||
pstyle.display = 'none'
|
||||
return
|
||||
}
|
||||
console.debug('Whoops, no permissions to access %s', endpoint)
|
||||
// TODO maybe just show button? but then it would need to be reactive..
|
||||
pstyle.display = 'block'
|
||||
}
|
||||
|
||||
|
||||
async function restoreOptions() {
|
||||
getSaveButton().addEventListener('click', saveOptions)
|
||||
|
||||
const opts = await getOptions()
|
||||
getEndpoint().value = opts.endpoint
|
||||
getDefaultTags().value = opts.default_tags
|
||||
getEnableNotification().checked = opts.notification
|
||||
await refreshPermissionValidation(opts.endpoint)
|
||||
}
|
||||
|
||||
|
||||
async function saveOptions() {
|
||||
// TODO could also check for permissions and display message?
|
||||
|
||||
const endpoint = getEndpoint().value
|
||||
await ensurePermissions(endpoint)
|
||||
refreshPermissionValidation(endpoint)
|
||||
|
||||
const opts = {
|
||||
endpoint: endpoint,
|
||||
default_tags: getDefaultTags().value,
|
||||
notification: getEnableNotification().checked,
|
||||
}
|
||||
await setOptions(opts)
|
||||
|
||||
// NOTE: seems that alert() only works here when we use open_in_tab: true for settings page
|
||||
// when open_in_tab: false, then
|
||||
// in chrome browser.extension.getBackgroundPage().alert works
|
||||
// in firefox that doesn't work, seems that it refuses to alert from bg page
|
||||
// so it seems easier to just always use opne_in_tab: true for setgings page
|
||||
alert('Saved!')
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', restoreOptions)
|
|
@ -1,15 +1,5 @@
|
|||
/* @flow */
|
||||
import {showNotification, awrap} from './common';
|
||||
import {getOptions} from './options';
|
||||
|
||||
|
||||
export function chromePermissionsRequest(params: any) {
|
||||
return awrap(chrome.permissions.request, params);
|
||||
}
|
||||
|
||||
export function chromePermissionsContains(params: any) {
|
||||
return awrap(chrome.permissions.contains, params);
|
||||
}
|
||||
import browser from "webextension-polyfill"
|
||||
|
||||
|
||||
function urlForPermissionsCheck(url: string): string {
|
||||
|
@ -23,23 +13,24 @@ function urlForPermissionsCheck(url: string): string {
|
|||
return u.toString();
|
||||
}
|
||||
|
||||
export function hasPermissions(endpoint: string) {
|
||||
export async function hasPermissions(endpoint: string): Promise<boolean> {
|
||||
const perms = {
|
||||
origins: [
|
||||
urlForPermissionsCheck(endpoint),
|
||||
],
|
||||
};
|
||||
return chromePermissionsContains(perms);
|
||||
}
|
||||
return browser.permissions.contains(perms)
|
||||
}
|
||||
|
||||
export function ensurePermissions(endpoint: string) {
|
||||
|
||||
export function ensurePermissions(endpoint: string): Promise<boolean> {
|
||||
const perms = {
|
||||
origins: [
|
||||
urlForPermissionsCheck(endpoint),
|
||||
],
|
||||
};
|
||||
}
|
||||
// shouldn't prompt if we already have the permission
|
||||
return chromePermissionsRequest(perms);
|
||||
return browser.permissions.request(perms)
|
||||
}
|
||||
|
||||
// just keeping the old one for the reference...
|
||||
|
@ -47,6 +38,7 @@ export function ensurePermissions(endpoint: string) {
|
|||
// TODO fuck. doesn't seem to work for Firefox at all...
|
||||
// just getting Error: "An unexpected error occurred" background.bundle.js
|
||||
// seems like this bug https://bugzilla.mozilla.org/show_bug.cgi?id=1382953
|
||||
/*
|
||||
function ensurePermissions_old(cb: () => void) { // eslint-disable-line no-unused-vars
|
||||
const back = chrome.extension.getBackgroundPage().console;
|
||||
|
||||
|
@ -82,3 +74,4 @@ function ensurePermissions_old(cb: () => void) { // eslint-disable-line no-unuse
|
|||
});
|
||||
// TODO improve message somehow???
|
||||
}
|
||||
*/
|
161
extension/src/js/popup.js
Normal file
161
extension/src/js/popup.js
Normal file
|
@ -0,0 +1,161 @@
|
|||
/* @flow */
|
||||
|
||||
|
||||
import {METHOD_CAPTURE_WITH_EXTRAS} from './common'
|
||||
import {getOptions} from './options'
|
||||
|
||||
// TODO template it in html too?
|
||||
const BUTTON_ID = 'button_id';
|
||||
const COMMENT_ID = 'comment_id';
|
||||
const TAGS_ID = 'tags_id';
|
||||
|
||||
|
||||
/*
|
||||
* normal popup logging is pretty annoying because chrome closes devtools
|
||||
*/
|
||||
/* eslint-disable no-unused-vars */
|
||||
function logbg(msg: any) {
|
||||
console.error('%o', msg)
|
||||
chrome.runtime.sendMessage({
|
||||
method: 'logging',
|
||||
source: 'popup.js',
|
||||
data: msg,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
type State = {|
|
||||
comment: string,
|
||||
tag_str: string,
|
||||
|}
|
||||
|
||||
|
||||
function saveState(state: ?State) {
|
||||
window.localStorage.setItem('state', JSON.stringify(state))
|
||||
}
|
||||
|
||||
|
||||
function clearState() {
|
||||
saveState(null)
|
||||
}
|
||||
|
||||
|
||||
function loadState(): ?State {
|
||||
const sts = window.localStorage.getItem('state') || null
|
||||
return JSON.parse(sts)
|
||||
}
|
||||
|
||||
|
||||
function getComment(): HTMLInputElement {
|
||||
const comment = ((document.getElementById(COMMENT_ID): any): HTMLInputElement);
|
||||
return comment;
|
||||
}
|
||||
|
||||
|
||||
function getTags(): HTMLInputElement {
|
||||
const tags = ((document.getElementById(TAGS_ID): any): HTMLInputElement);
|
||||
return tags;
|
||||
}
|
||||
|
||||
|
||||
function getButton(): HTMLElement {
|
||||
const button = ((document.getElementById(BUTTON_ID): any): HTMLElement);
|
||||
return button;
|
||||
}
|
||||
|
||||
|
||||
function getUiState(): State {
|
||||
const comment_text = getComment().value;
|
||||
const tag_str = getTags().value;
|
||||
return {
|
||||
'comment': comment_text,
|
||||
'tag_str': tag_str,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function restoreState(state: ?State) {
|
||||
window.justSubmitted = false
|
||||
if (state == null) {
|
||||
// comment just relies on default
|
||||
const opts = await getOptions()
|
||||
getTags().value = opts.default_tags
|
||||
} else {
|
||||
getComment().value = state.comment
|
||||
getTags().value = state.tag_str
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function submitCapture () {
|
||||
const state = getUiState()
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
'method': METHOD_CAPTURE_WITH_EXTRAS,
|
||||
...state,
|
||||
}, () => {
|
||||
window.justSubmitted = true
|
||||
clearState()
|
||||
|
||||
// TODO not sure about this?
|
||||
window.close()
|
||||
console.log("[popup] captured!");
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
// todo not sure if can define const here??
|
||||
var ctrlEnterSubmit: KeyboardEventListener = (e) => {
|
||||
// note: there is also e.metaKey which triggers on mac when cmd is pressed
|
||||
// but it doesn't seem to allow chords like cmd+Enter
|
||||
if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') {
|
||||
submitCapture()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// https://stackoverflow.com/a/6003829/706389
|
||||
function moveCaretToEnd(el: HTMLInputElement) {
|
||||
if (typeof el.selectionStart == "number") {
|
||||
el.selectionStart = el.selectionEnd = el.value.length;
|
||||
// $FlowFixMe https://github.com/facebook/flow/issues/8705
|
||||
} else if (typeof el.createTextRange != "undefined") {
|
||||
el.focus();
|
||||
var range = el.createTextRange();
|
||||
range.collapse(false);
|
||||
range.select();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function setupPage () {
|
||||
const comment = getComment();
|
||||
comment.focus();
|
||||
comment.addEventListener('keydown', ctrlEnterSubmit);
|
||||
|
||||
const tags = getTags();
|
||||
tags.addEventListener('keydown', ctrlEnterSubmit);
|
||||
tags.addEventListener('focus', () => moveCaretToEnd(tags)); // to put cursor to the end of tags when tabbed
|
||||
|
||||
getButton().addEventListener('click', submitCapture)
|
||||
|
||||
const state = loadState()
|
||||
restoreState(state)
|
||||
}
|
||||
|
||||
|
||||
// hmm interesting that on chrome we can use visibilitychange for both
|
||||
// on firefox dev edition however 'visible' doesn't fire, only 'hidden' :shrug:
|
||||
document.addEventListener('DOMContentLoaded', setupPage)
|
||||
window.addEventListener('visibilitychange', (_: Event) => {
|
||||
// event doesn't contain visibility status
|
||||
// docs recomment to use this instead of unload
|
||||
const visible = document.visibilityState === 'visible'
|
||||
if (!visible) {
|
||||
// if we just sent, no need to save anything
|
||||
if (!window.justSubmitted) {
|
||||
saveState(getUiState())
|
||||
}
|
||||
}
|
||||
})
|
11
extension/src/manifest.json
Normal file
11
extension/src/manifest.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "<webpack managed>",
|
||||
"version": "<webpack managed>",
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"icons": {
|
||||
"128": "img/unicorn.png"
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script type="text/javascript" src="webextension-polyfill.js"></script>
|
||||
<script type="text/javascript" src="options_page.js"/></script>
|
||||
<style>
|
||||
input:invalid {
|
|
@ -2,6 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script type="text/javascript" src="webextension-polyfill.js"></script>
|
||||
<script type="text/javascript" src="popup.js"></script>
|
||||
</head>
|
||||
<body>
|
241
extension/webpack.config.js
Normal file
241
extension/webpack.config.js
Normal file
|
@ -0,0 +1,241 @@
|
|||
const webpack = require('webpack'),
|
||||
path = require('path'),
|
||||
{CleanWebpackPlugin} = require('clean-webpack-plugin'),
|
||||
CopyWebpackPlugin = require('copy-webpack-plugin'),
|
||||
WebpackExtensionManifestPlugin = require('webpack-extension-manifest-plugin');
|
||||
|
||||
const T = {
|
||||
CHROME : 'chrome',
|
||||
FIREFOX: 'firefox',
|
||||
};
|
||||
|
||||
|
||||
// TODO will be conditional on T.CHROME at some point
|
||||
const v3 = false
|
||||
|
||||
const env = {
|
||||
TARGET : process.env.TARGET,
|
||||
RELEASE: process.env.RELEASE,
|
||||
PUBLISH: process.env.PUBLISH,
|
||||
}
|
||||
const ext_id = process.env.EXT_ID
|
||||
|
||||
const pkg = require('./package.json');
|
||||
const baseManifest = require('./src/manifest.json');
|
||||
|
||||
const release = env.RELEASE == 'YES' ? true : false;
|
||||
const publish = env.PUBLISH == 'YES' ? true : false;
|
||||
const dev = !release; // meh. maybe make up my mind?
|
||||
const target = env.TARGET;
|
||||
|
||||
const name = "grasp" + (dev ? ' [dev]' : '');
|
||||
|
||||
|
||||
// ugh. declarative formats are shit.
|
||||
|
||||
// Firefox wouldn't let you rebind its default shortcuts most of which use Shift
|
||||
// On the other hand, Chrome wouldn't let you use Alt
|
||||
const modifier = target === T.CHROME ? 'Shift' : 'Alt'
|
||||
|
||||
const action_name = v3 ? 'action' : 'browser_action'
|
||||
|
||||
const commands = {
|
||||
"capture-simple": {
|
||||
"description": "Quick capture: url, title and selection",
|
||||
"suggested_key": {
|
||||
"default": `Ctrl+${modifier}+C`,
|
||||
"mac": `Command+${modifier}+C`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
commands[`_execute_${action_name}`] = {
|
||||
"description": "Capture page, with extra information",
|
||||
"suggested_key": {
|
||||
"default": `Ctrl+${modifier}+Y`,
|
||||
"mac": `Command+${modifier}+Y`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
const action = {
|
||||
"default_icon": "img/unicorn.png",
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Capture page, with extra information",
|
||||
}
|
||||
if (target == T.FIREFOX) {
|
||||
action['browser_style'] = true
|
||||
}
|
||||
|
||||
|
||||
const endpoints = (domain) => [
|
||||
"http://" + domain + "/capture",
|
||||
"https://" + domain + "/capture",
|
||||
]
|
||||
|
||||
|
||||
// prepare for manifest v3
|
||||
const host_permissions = endpoints('localhost')
|
||||
const optional_host_permissions = endpoints('*')
|
||||
|
||||
|
||||
// TODO make permissions literate
|
||||
const permissions = [
|
||||
"storage",
|
||||
"notifications",
|
||||
|
||||
// need to query active tab and get its url/title
|
||||
"activeTab",
|
||||
]
|
||||
|
||||
|
||||
const optional_permissions = []
|
||||
|
||||
if (target === T.FIREFOX || v3) {
|
||||
// chrome v2 doesn't support scripting api
|
||||
// code has a fallback just for that
|
||||
// (needed to get selected text)
|
||||
permissions.push("scripting")
|
||||
}
|
||||
|
||||
|
||||
const content_security_policy = [
|
||||
"default-src 'self'",
|
||||
"connect-src " + endpoints('*:*').join(' '),
|
||||
|
||||
// FFS, otherwise <style> directives on extension's pages not working??
|
||||
"style-src 'unsafe-inline'",
|
||||
].join('; ')
|
||||
|
||||
|
||||
const manifestExtra = {
|
||||
name: name,
|
||||
version: pkg.version,
|
||||
description: pkg.description,
|
||||
permissions: permissions,
|
||||
commands: commands,
|
||||
optional_permissions: optional_permissions,
|
||||
manifest_version: v3 ? 3 : 2,
|
||||
background: (v3 ? {
|
||||
service_worker: 'background.js',
|
||||
// wtf -- firefox doesn't support module??
|
||||
// it just fails to load manifest in this case atm
|
||||
...(target === T.FIREFOX? {} : {type: 'module'}),
|
||||
} : {
|
||||
scripts: [
|
||||
'webextension-polyfill.js',
|
||||
'background.js',
|
||||
],
|
||||
persistent: false,
|
||||
}),
|
||||
content_security_policy: v3 ? {
|
||||
extension_pages: content_security_policy,
|
||||
} : content_security_policy,
|
||||
}
|
||||
manifestExtra[action_name] = action
|
||||
|
||||
if (v3) {
|
||||
manifestExtra['host_permissions'] = host_permissions
|
||||
manifestExtra['optional_host_permissions'] = optional_host_permissions
|
||||
if (target === T.FIREFOX) {
|
||||
manifestExtra['browser_specific_settings'] = {
|
||||
"gecko": {
|
||||
"id": ext_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
manifestExtra.permissions.push(...host_permissions)
|
||||
manifestExtra.optional_permissions.push(...optional_host_permissions)
|
||||
}
|
||||
|
||||
|
||||
if (target === T.FIREFOX) {
|
||||
manifestExtra.options_ui = {browser_style: true};
|
||||
}
|
||||
|
||||
const buildPath = path.join(__dirname, 'dist', target);
|
||||
|
||||
const options = {
|
||||
mode: dev ? 'development' : 'production', // TODO map directly from MODE env var?
|
||||
node: {
|
||||
// no idea what does it mean... https://github.com/webpack/webpack/issues/5627#issuecomment-394290231
|
||||
// but it does get rid of some Function() which webpacka apparently generates
|
||||
global: false,
|
||||
},
|
||||
entry: {
|
||||
background : path.join(__dirname, "src", "js", "background"),
|
||||
popup : path.join(__dirname, "src", "js", "popup"),
|
||||
options_page: path.join(__dirname, "src", "js", "options_page"),
|
||||
},
|
||||
output: {
|
||||
path: buildPath,
|
||||
},
|
||||
optimization: {
|
||||
// https://webpack.js.org/configuration/optimization
|
||||
// don't think minimize worth it for such a tiny extension
|
||||
minimize: false,
|
||||
|
||||
// split chunks is so vendors split out into separate js files
|
||||
// to prevent bloating individual source files
|
||||
// seems that at the moment we need to manually load the chunks in the corresponding
|
||||
// html files or manifest
|
||||
// split chunks doc recommend using webpack html plugin??
|
||||
splitChunks: {
|
||||
// seems necessary, otherwise doesn't split out polyfill??
|
||||
chunks: 'all',
|
||||
cacheGroups: {
|
||||
vendor: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name(module) {
|
||||
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
|
||||
return packageName
|
||||
},
|
||||
// create chunk regardless size etc
|
||||
enforce: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: 'style-loader!css-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
loader: 'html-loader',
|
||||
exclude: /node_modules/
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin(), // ok, respects symlinks
|
||||
|
||||
// without copy pluging, webpack only bundles js/json files
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{ context: 'src', from: '**/*.html' },
|
||||
{ context: 'src', from: '**/*.png' },
|
||||
]
|
||||
}),
|
||||
new WebpackExtensionManifestPlugin({
|
||||
config: {
|
||||
base: baseManifest,
|
||||
extend: manifestExtra,
|
||||
}
|
||||
}),
|
||||
],
|
||||
// docs claim it's the slowest but pretty fast anyway
|
||||
devtool: 'source-map',
|
||||
}
|
||||
|
||||
|
||||
module.exports = options;
|
10
mypy.ini
Normal file
10
mypy.ini
Normal file
|
@ -0,0 +1,10 @@
|
|||
[mypy]
|
||||
pretty = True
|
||||
show_error_context = True
|
||||
show_error_codes = True
|
||||
check_untyped_defs = True
|
||||
namespace_packages = True
|
||||
|
||||
# an example of suppressing
|
||||
# [mypy-my.config.repos.pdfannots.pdfannots]
|
||||
# ignore_errors = True
|
33
package.json
33
package.json
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"name": "grasp",
|
||||
"version": "0.6.6",
|
||||
"description": "Reliably capture links and webpage content in a plaintext file (org-mode/markdown)",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
"build": "webpack --progress --profile --colors",
|
||||
"watch": "webpack --watch --progress --colors",
|
||||
"dev": "npm run watch",
|
||||
"eslint": "eslint src",
|
||||
"flow": "flow"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.2.2",
|
||||
"@babel/preset-env": "^7.2.3",
|
||||
"@babel/preset-flow": "^7.0.0",
|
||||
"babel-loader": "^8.0.5",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"clean-webpack-plugin": "^1.0.0",
|
||||
"copy-webpack-plugin": "^4.2.0",
|
||||
"css-loader": "^2.1.0",
|
||||
"eslint": "^5.12.0",
|
||||
"eslint-config-google": "^0.11.0",
|
||||
"eslint-plugin-flowtype": "^3.2.0",
|
||||
"flow-bin": "^0.89.0",
|
||||
"flow-interfaces-chrome": "^0.5.3",
|
||||
"style-loader": "^1.0.0",
|
||||
"webpack": "^4.28.3",
|
||||
"webpack-cli": "^3.2.0",
|
||||
"webpack-dev-server": "^3.1.14",
|
||||
"webpack-extension-manifest-plugin": "^0.4.3"
|
||||
}
|
||||
}
|
22
publish
22
publish
|
@ -1,22 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -eux
|
||||
|
||||
|
||||
VERSION="$(git tag --points-at)"
|
||||
if [[ ! -n $VERSION ]]; then
|
||||
>&2 echo "Dirty build!"
|
||||
VERSION="$(git rev-parse --short HEAD)"
|
||||
fi
|
||||
|
||||
|
||||
function rebuild {
|
||||
local res="$1"
|
||||
local browser="$2"
|
||||
./build --target "$browser" --release --lint
|
||||
zip -r -j "$res" dist/*
|
||||
unzip -l "$res"
|
||||
}
|
||||
|
||||
for browser in "chrome" "firefox"; do
|
||||
rebuild "grasp-$VERSION-$browser.zip" "$browser"
|
||||
done
|
13
pytest.ini
Normal file
13
pytest.ini
Normal file
|
@ -0,0 +1,13 @@
|
|||
[pytest]
|
||||
# discover files that don't follow test_ naming. Useful to keep tests along with the source code
|
||||
python_files = *.py
|
||||
addopts =
|
||||
# -rap to print tests summary even when they are successful
|
||||
-rap
|
||||
--verbose
|
||||
|
||||
# otherwise it won't discover doctests
|
||||
--doctest-modules
|
||||
|
||||
# show all test durations (unless they are too short)
|
||||
--durations=0
|
|
@ -23,11 +23,11 @@ def empty(s) -> bool:
|
|||
# TODO put template in config??
|
||||
class Config:
|
||||
@staticmethod
|
||||
def format_selection(selection: str) -> List[str]:
|
||||
def format_selection(selection: str) -> List[str]: # type: ignore[empty-body]
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def format_comment(comment: str) -> List[str]:
|
||||
def format_comment(comment: str) -> List[str]: # type: ignore[empty-body]
|
||||
...
|
||||
|
||||
|
||||
|
@ -56,6 +56,8 @@ def as_org(
|
|||
tags: List[str],
|
||||
org_template: str,
|
||||
config: Optional[Config] = None,
|
||||
*,
|
||||
_now=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.
|
||||
|
@ -64,10 +66,11 @@ Additionally, supports :selection, :comment and :tags expansions.
|
|||
|
||||
You can look at `test_templates` for some specific examples.
|
||||
"""
|
||||
# py_template = re.sub(r'%[:]?([?\w]+)', r'{\1}', org_template)
|
||||
py_template = re.sub(r'[^\\]%[:]?([?\w]+)', r'{\1}', org_template)
|
||||
py_template = re.sub(r'([^\\])%[:]?([?\w]+)', r'\1{\2}', org_template)
|
||||
# replace all escaped % with regular %, safe to do after substitution
|
||||
py_template = py_template.replace(r'\%', '%')
|
||||
|
||||
NOW = datetime.now()
|
||||
NOW = datetime.now() if _now is None else _now
|
||||
org_date = date2org(NOW)
|
||||
org_datetime = datetime2org(NOW)
|
||||
|
||||
|
@ -104,9 +107,8 @@ You can look at `test_templates` for some specific examples.
|
|||
)
|
||||
return res
|
||||
|
||||
# TODO escaping?
|
||||
# just checks it's not crashing for now
|
||||
def test_templates():
|
||||
|
||||
def test_templates() -> None:
|
||||
url = 'https://whatever'
|
||||
title = 'hello'
|
||||
selection = """some
|
||||
|
@ -116,6 +118,7 @@ def test_templates():
|
|||
comment = 'fafewfewf'
|
||||
tags = ['aba', 'caba']
|
||||
|
||||
vimzettel_template = r'zettel \%title [%:description] testing'
|
||||
# https://orgmode.org/guide/Capture-templates.html
|
||||
org_templates = [
|
||||
DEFAULT_TEMPLATE,
|
||||
|
@ -135,7 +138,9 @@ def test_templates():
|
|||
%:selection
|
||||
#+END_QUOTE
|
||||
""",
|
||||
vimzettel_template,
|
||||
]
|
||||
# check they aren't crashing
|
||||
for org_template in org_templates:
|
||||
res = as_org(
|
||||
url,
|
||||
|
@ -146,3 +151,39 @@ def test_templates():
|
|||
org_template=org_template
|
||||
|
||||
)
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
print()
|
||||
print(res)
|
||||
res = as_org(
|
||||
url,
|
||||
title,
|
||||
selection,
|
||||
comment,
|
||||
tags,
|
||||
org_template=DEFAULT_TEMPLATE,
|
||||
_now=datetime(1111, 11, 11),
|
||||
)
|
||||
expected = '''
|
||||
* [1111-11-11 Sat 00:00] hello :aba:caba:
|
||||
https://whatever
|
||||
Selection:
|
||||
some
|
||||
selected
|
||||
text
|
||||
|
||||
Comment:
|
||||
fafewfewf
|
||||
'''.lstrip()
|
||||
assert res == expected
|
||||
|
||||
res = as_org(
|
||||
url,
|
||||
title,
|
||||
selection,
|
||||
comment,
|
||||
tags,
|
||||
org_template=vimzettel_template,
|
||||
)
|
||||
assert res == 'zettel %title [hello] testing'
|
||||
>>>>>>> upstream/master
|
||||
|
|
113
server/setup
113
server/setup
|
@ -1,12 +1,18 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import shlex
|
||||
import os
|
||||
from pathlib import Path
|
||||
from subprocess import check_call, run
|
||||
import platform
|
||||
import shlex
|
||||
from subprocess import check_call, run, DEVNULL
|
||||
|
||||
from grasp_server import setup_parser
|
||||
|
||||
SYSTEMD_CONFIG = """
|
||||
|
||||
SYSTEM = platform.system()
|
||||
|
||||
|
||||
SYSTEMD_TEMPLATE = """
|
||||
[Unit]
|
||||
Description=Grasp extension server conterpart
|
||||
|
||||
|
@ -17,7 +23,29 @@ WantedBy=default.target
|
|||
ExecStart={server} {extra_args}
|
||||
Type=simple
|
||||
Restart=always
|
||||
"""
|
||||
""".lstrip()
|
||||
|
||||
|
||||
LAUNCHD_TEMPLATE = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>{service_name}</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
{arguments}
|
||||
</array>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
'''.lstrip()
|
||||
|
||||
|
||||
def systemd(*args, method=check_call):
|
||||
|
@ -25,31 +53,36 @@ def systemd(*args, method=check_call):
|
|||
'systemctl', '--user', *args,
|
||||
])
|
||||
|
||||
def setup(args):
|
||||
|
||||
def get_command(args) -> list[str]:
|
||||
return [
|
||||
'--port', args.port,
|
||||
'--path', args.path,
|
||||
'--template', args.template,
|
||||
*([] if args.config is None else ['--config' , args.config]),
|
||||
]
|
||||
|
||||
|
||||
def setup_systemd(args) -> None:
|
||||
args.template = args.template.replace('%', '%%').replace('\n', r'\n') # escape for systemd...
|
||||
args.unit_name = args.unit_name.removesuffix('.service') # legacy unit_name default had .service
|
||||
|
||||
base_path = '~/.config/systemd/user'
|
||||
if not Path(base_path).expanduser().is_dir():
|
||||
Path(base_path).expanduser().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
unit_name = args.unit_name
|
||||
out = Path(f'{base_path}/{unit_name}').expanduser()
|
||||
print(f"Writing systemd config to {out}:")
|
||||
out = Path(f'{base_path}/{unit_name}.service').expanduser()
|
||||
print(f"Writing unit file to {out}")
|
||||
|
||||
server_bin = Path(__file__).parent.joinpath('grasp_server.py').absolute()
|
||||
command = get_command(args)
|
||||
extra_args = ' '.join(map(shlex.quote, command))
|
||||
|
||||
template = args.template.replace('%', '%%').replace('\n', r'\n') # escape for systemd...
|
||||
args = [
|
||||
'--port', args.port,
|
||||
'--path', args.path,
|
||||
'--template', template,
|
||||
*([] if args.config is None else ['--config' , args.config]),
|
||||
]
|
||||
extra_args = ' '.join(map(shlex.quote, args))
|
||||
|
||||
with out.open('w') as fo:
|
||||
fo.write(SYSTEMD_CONFIG.format(
|
||||
out.write_text(SYSTEMD_TEMPLATE.format(
|
||||
server=server_bin,
|
||||
extra_args=extra_args,
|
||||
))
|
||||
))
|
||||
try:
|
||||
systemd('stop' , unit_name, method=run) # ignore errors here if it wasn't running in the first place
|
||||
systemd('daemon-reload')
|
||||
|
@ -60,12 +93,48 @@ def setup(args):
|
|||
print(f"Something has gone wrong... you might want to use 'journalctl --user -u {unit_name}' to debug")
|
||||
raise e
|
||||
|
||||
def main():
|
||||
|
||||
def setup_launchd(args) -> None:
|
||||
# launchd keep everything as xml and treats whitespace literally
|
||||
args.template = args.template.replace(r'\n', '\n')
|
||||
|
||||
base_path = Path('~/Library/LaunchAgents')
|
||||
|
||||
unit_name = args.unit_name
|
||||
out = Path(f'{base_path}/{unit_name}.plist').expanduser()
|
||||
print(f"Writing unit file to {out}")
|
||||
|
||||
server_bin = Path(__file__).parent.joinpath('grasp_server.py').absolute()
|
||||
command = get_command(args)
|
||||
|
||||
|
||||
command = [str(server_bin)] + command
|
||||
arguments = '\n'.join(f'<string>{a}</string>' for a in command)
|
||||
|
||||
out.write_text(LAUNCHD_TEMPLATE.format(
|
||||
service_name=unit_name,
|
||||
arguments=arguments,
|
||||
))
|
||||
|
||||
DOMAIN = f'gui/{os.getuid()}'
|
||||
# defensive since might not have been running
|
||||
run(['launchctl', 'bootout', DOMAIN + '/' + unit_name], stdout=DEVNULL, stderr=DEVNULL)
|
||||
check_call(['launchctl', 'bootstrap', DOMAIN, out])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
default_unit_name = 'grasp' if SYSTEM == 'Linux' else 'com.github.karlicoss.grasp'
|
||||
|
||||
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=default_unit_name, help='systemd/launchd unit name')
|
||||
setup_parser(p)
|
||||
args = p.parse_args()
|
||||
setup(args)
|
||||
if SYSTEM == 'Linux':
|
||||
setup_systemd(args)
|
||||
else: # assume macos
|
||||
setup_launchd(args)
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
|
@ -4,16 +4,10 @@ from subprocess import Popen, check_call
|
|||
from time import sleep
|
||||
|
||||
|
||||
# ugh.
|
||||
import requests
|
||||
|
||||
|
||||
@contextmanager
|
||||
def killmepls(*args, **kwargs):
|
||||
with Popen(*args, **kwargs) as p:
|
||||
try:
|
||||
yield p
|
||||
finally:
|
||||
p.kill()
|
||||
|
||||
|
||||
def grasp_test_server(capture_file: Path, port: str, template=None):
|
||||
server = str((Path(__file__).parent / 'grasp_server.py').absolute())
|
||||
cmdline = [
|
||||
|
@ -21,23 +15,29 @@ def grasp_test_server(capture_file: Path, port: str, template=None):
|
|||
]
|
||||
if template is not None:
|
||||
cmdline.extend(['--template', template])
|
||||
return killmepls(cmdline)
|
||||
with Popen(cmdline) as p:
|
||||
try:
|
||||
yield p
|
||||
finally:
|
||||
p.kill()
|
||||
|
||||
|
||||
def test_server(tmp_path: Path):
|
||||
def test_server(tmp_path: Path) -> None:
|
||||
cfile = tmp_path / 'test-capture.org'
|
||||
PORT = '17890'
|
||||
|
||||
def send(url: str, title: str):
|
||||
# TODO eh, not sure if there is anything easier than httpie
|
||||
check_call([
|
||||
'http', '--ignore-stdin', 'POST', f'http://localhost:{PORT}',
|
||||
f'url={url}',
|
||||
f'title={title}',
|
||||
'selection=null',
|
||||
'comment=null',
|
||||
'tag_str=null',
|
||||
])
|
||||
r = requests.post(
|
||||
f'http://localhost:{PORT}',
|
||||
json = {
|
||||
'url': url,
|
||||
'title': title,
|
||||
'selection': None,
|
||||
'comment': None,
|
||||
'tag_str': None,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r
|
||||
|
||||
|
||||
with grasp_test_server(capture_file=cfile, port=PORT):
|
||||
|
|
57
setup.py
Normal file
57
setup.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
# see https://github.com/karlicoss/pymplate for up-to-date reference
|
||||
|
||||
|
||||
from setuptools import setup, find_namespace_packages # type: ignore
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# works with both ordinary and namespace packages
|
||||
#pkgs = find_namespace_packages('src')
|
||||
pkgs = ['server']
|
||||
pkg = min(pkgs) # lexicographically smallest is the correct one usually?
|
||||
setup(
|
||||
name=pkg,
|
||||
use_scm_version={
|
||||
'version_scheme': 'python-simplified-semver',
|
||||
'local_scheme': 'dirty-tag',
|
||||
},
|
||||
setup_requires=['setuptools_scm'],
|
||||
|
||||
# otherwise mypy won't work
|
||||
# https://mypy.readthedocs.io/en/stable/installed_packages.html#making-pep-561-compatible-packages
|
||||
zip_safe=False,
|
||||
|
||||
packages=pkgs,
|
||||
#package_dir={'': 'src'},
|
||||
# necessary so that package works with mypy
|
||||
package_data={pkg: ['py.typed']},
|
||||
|
||||
## ^^^ this should be mostly automatic and not requiring any changes
|
||||
|
||||
install_requires=[
|
||||
# vvv example of git repo dependency
|
||||
# 'repo @ git+https://github.com/karlicoss/repo.git',
|
||||
|
||||
# vvv example of local file dependency. yes, DUMMY is necessary for some reason
|
||||
# 'repo @ git+file://DUMMY/path/to/repo',
|
||||
],
|
||||
extras_require={
|
||||
'testing': ['pytest', 'requests'],
|
||||
'linting': ['pytest', 'mypy', 'lxml'], # lxml for mypy coverage report
|
||||
},
|
||||
|
||||
|
||||
# this needs to be set if you're planning to upload to pypi
|
||||
# url='',
|
||||
# author='',
|
||||
# author_email='',
|
||||
# description='',
|
||||
|
||||
# Rest of the stuff -- classifiers, license, etc, I don't think it matters for pypi
|
||||
# it's just unnecessary duplication
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 2.8 KiB |
|
@ -1,124 +0,0 @@
|
|||
/* @flow */
|
||||
// TODO do I really need to annotate all files with @flow??
|
||||
|
||||
import {COMMAND_CAPTURE_SIMPLE, METHOD_CAPTURE_WITH_EXTRAS, showNotification} from './common';
|
||||
import {get_options} from './options';
|
||||
|
||||
type Params = {
|
||||
url: string,
|
||||
title: ?string,
|
||||
selection: ?string,
|
||||
comment: ?string,
|
||||
tag_str: ?string,
|
||||
}
|
||||
|
||||
|
||||
function makeCaptureRequest(
|
||||
params: Params,
|
||||
options,
|
||||
) {
|
||||
if (params.tag_str == null) {
|
||||
params.tag_str = options.default_tags;
|
||||
}
|
||||
|
||||
const data = JSON.stringify(params);
|
||||
console.log(`[background] capturing ${data}`);
|
||||
|
||||
var request = new XMLHttpRequest();
|
||||
const curl = options.endpoint;
|
||||
|
||||
request.timeout = 10 * 1000; // TODO should it be configurable?
|
||||
request.open('POST', curl, true);
|
||||
request.onreadystatechange = () => {
|
||||
const XHR_DONE = 4;
|
||||
if (request.readyState != XHR_DONE) {
|
||||
return;
|
||||
}
|
||||
const status = request.status;
|
||||
const rtext = request.responseText;
|
||||
var had_error = false;
|
||||
var error_message = `status ${status}, response ${rtext}`;
|
||||
console.log(`[background] status: ${status}, response: ${rtext}`);
|
||||
if (status >= 200 && status < 400) { // success
|
||||
try {
|
||||
// TODO handle json parsing defensively here
|
||||
const response = JSON.parse(rtext);
|
||||
const path = response.path;
|
||||
console.log(`[background] success: ${rtext}`);
|
||||
if (options.notification) {
|
||||
showNotification(`OK: captured to ${path}`);
|
||||
}
|
||||
} catch (err) {
|
||||
had_error = true;
|
||||
error_message = error_message.concat(String(err));
|
||||
console.error(err);
|
||||
}
|
||||
} else {
|
||||
had_error = true;
|
||||
if (status == 0) {
|
||||
error_message = error_message.concat(` ${curl} must be unavailable `);
|
||||
}
|
||||
}
|
||||
|
||||
if (had_error) {
|
||||
console.error(`[background] ERROR: ${error_message}`);
|
||||
showNotification(`ERROR: ${error_message}`, 1);
|
||||
// TODO crap, doesn't really seem to respect urgency...
|
||||
}
|
||||
};
|
||||
request.onerror = () => {
|
||||
console.error(request);
|
||||
};
|
||||
|
||||
request.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
|
||||
request.send(data);
|
||||
}
|
||||
|
||||
|
||||
// TODO FIXME ugh. need defensive error handling on the very top...
|
||||
function capture(comment: ?string = null, tag_str: ?string = null) {
|
||||
chrome.tabs.query({currentWindow: true, active: true }, tabs => {
|
||||
const tab = tabs[0];
|
||||
if (tab.url == null) {
|
||||
showNotification('ERROR: trying to capture null');
|
||||
return;
|
||||
}
|
||||
const url: string = tab.url;
|
||||
const title: ?string = tab.title;
|
||||
|
||||
get_options(opts => {
|
||||
// console.log('action!');
|
||||
// ugh.. https://stackoverflow.com/a/19165930/706389
|
||||
chrome.tabs.executeScript( {
|
||||
code: "window.getSelection().toString();"
|
||||
}, selections => {
|
||||
const selection = selections == null ? null : selections[0];
|
||||
makeCaptureRequest({
|
||||
url: url,
|
||||
title: title,
|
||||
selection: selection,
|
||||
comment: comment,
|
||||
tag_str: tag_str,
|
||||
}, opts);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
chrome.commands.onCommand.addListener(command => {
|
||||
if (command === COMMAND_CAPTURE_SIMPLE) {
|
||||
capture(null, null);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message: any, sender: chrome$MessageSender, sendResponse) => { // eslint-disable-line no-unused-vars
|
||||
if (message.method === METHOD_CAPTURE_WITH_EXTRAS) {
|
||||
const comment = message.comment;
|
||||
const tag_str = message.tag_str;
|
||||
capture(comment, tag_str);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO handle cannot access chrome:// url??
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
/* @flow */
|
||||
|
||||
type Options = {
|
||||
endpoint: string;
|
||||
default_tags: string;
|
||||
notification: boolean;
|
||||
}
|
||||
|
||||
function default_options(): Options {
|
||||
return {
|
||||
endpoint: "http://localhost:12212/capture",
|
||||
default_tags: "grasp",
|
||||
notification: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function get_options(cb: (Options) => void) {
|
||||
chrome.storage.local.get(null, res => {
|
||||
res = {...default_options(), ...res};
|
||||
cb(res);
|
||||
});
|
||||
}
|
||||
|
||||
export function set_options(opts: Options, cb: () => void) {
|
||||
console.log('Saving %s', JSON.stringify(opts));
|
||||
chrome.storage.local.set(opts, cb);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getOptions(): Promise<Options> {
|
||||
return new Promise((resolve) => {
|
||||
// TODO FIXME reject?
|
||||
get_options(resolve);
|
||||
});
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
/* @flow */
|
||||
import {get_options, set_options} from './options';
|
||||
import {ensurePermissions, hasPermissions} from './permissions';
|
||||
|
||||
const ENDPOINT_ID = 'endpoint_id';
|
||||
const HAS_PERMISSION_ID = 'has_permission_id';
|
||||
const NOTIFICATION_ID = 'notification_id';
|
||||
const DEFAULT_TAGS_ID = 'default_tags_id';
|
||||
// TODO specify capture path here?
|
||||
|
||||
const SAVE_ID = 'save_id';
|
||||
|
||||
function getEndpoint(): HTMLInputElement {
|
||||
return ((document.getElementById(ENDPOINT_ID): any): HTMLInputElement);
|
||||
}
|
||||
|
||||
function getDefaultTags(): HTMLInputElement {
|
||||
return ((document.getElementById(DEFAULT_TAGS_ID): any): HTMLInputElement);
|
||||
// TODO if empty, return null?
|
||||
}
|
||||
|
||||
function getEnableNotification(): HTMLInputElement {
|
||||
return ((document.getElementById(NOTIFICATION_ID): any): HTMLInputElement);
|
||||
}
|
||||
|
||||
function getSaveButton(): HTMLInputElement {
|
||||
return ((document.getElementById(SAVE_ID): any): HTMLInputElement);
|
||||
}
|
||||
|
||||
function getHasPermission(): HTMLElement {
|
||||
return ((document.getElementById(HAS_PERMISSION_ID): any): HTMLElement);
|
||||
}
|
||||
|
||||
// ugh. needs a better name..
|
||||
function refreshPermissionValidation(endpoint: string) {
|
||||
hasPermissions(endpoint).then(has => {
|
||||
const pstyle = getHasPermission().style;
|
||||
if (has) {
|
||||
console.debug('Got permisssions, nothing to worry about.');
|
||||
pstyle.display = 'none';
|
||||
return;
|
||||
}
|
||||
console.debug('Whoops, no permissions to access %s', endpoint);
|
||||
// TODO maybe just show button? but then it would need to be reactive..
|
||||
pstyle.display = 'block';
|
||||
});
|
||||
}
|
||||
|
||||
function restoreOptions() {
|
||||
getSaveButton().addEventListener('click', saveOptions);
|
||||
get_options(opts => {
|
||||
const ep = opts.endpoint;
|
||||
getEndpoint().value = ep;
|
||||
getDefaultTags().value = opts.default_tags;
|
||||
getEnableNotification().checked = opts.notification;
|
||||
refreshPermissionValidation(ep);
|
||||
});
|
||||
}
|
||||
|
||||
function saveOptions() {
|
||||
// TODO could also check for permissions and display message?
|
||||
|
||||
const endpoint = getEndpoint().value;
|
||||
ensurePermissions(endpoint).finally(() => {
|
||||
refreshPermissionValidation(endpoint);
|
||||
});
|
||||
|
||||
const opts = {
|
||||
endpoint: endpoint,
|
||||
default_tags: getDefaultTags().value,
|
||||
notification: getEnableNotification().checked,
|
||||
};
|
||||
set_options(opts, () => { alert('Saved!'); });
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', restoreOptions);
|
120
src/js/popup.js
120
src/js/popup.js
|
@ -1,120 +0,0 @@
|
|||
/* @flow */
|
||||
|
||||
import {METHOD_CAPTURE_WITH_EXTRAS} from './common';
|
||||
import {get_options} from './options';
|
||||
|
||||
// TODO template it in html too?
|
||||
const BUTTON_ID = 'button_id';
|
||||
const COMMENT_ID = 'comment_id';
|
||||
const TAGS_ID = 'tags_id';
|
||||
|
||||
|
||||
type State = {
|
||||
comment: string,
|
||||
tag_str: string,
|
||||
};
|
||||
|
||||
|
||||
function save_state(state: ?State) {
|
||||
window.localStorage.setItem('state', JSON.stringify(state));
|
||||
}
|
||||
|
||||
function load_state(): ?State {
|
||||
const sts = window.localStorage.getItem('state') || null;
|
||||
return JSON.parse(sts);
|
||||
}
|
||||
|
||||
function getComment(): HTMLInputElement {
|
||||
const comment = ((document.getElementById(COMMENT_ID): any): HTMLInputElement);
|
||||
return comment;
|
||||
}
|
||||
|
||||
function getTags(): HTMLInputElement {
|
||||
const tags = ((document.getElementById(TAGS_ID): any): HTMLInputElement);
|
||||
return tags;
|
||||
}
|
||||
|
||||
function getButton(): HTMLElement {
|
||||
const button = ((document.getElementById(BUTTON_ID): any): HTMLElement);
|
||||
return button;
|
||||
}
|
||||
|
||||
function getState(): State {
|
||||
const comment_text = getComment().value;
|
||||
const tag_str = getTags().value;
|
||||
return {
|
||||
'comment': comment_text,
|
||||
'tag_str': tag_str,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreState(state: ?State) {
|
||||
if (state == null) {
|
||||
// comment just relies on default
|
||||
get_options(opts => {getTags().value = opts.default_tags;});
|
||||
} else {
|
||||
getComment().value = state.comment;
|
||||
getTags().value = state.tag_str;
|
||||
}
|
||||
}
|
||||
|
||||
function submitComment () {
|
||||
// TODO focus
|
||||
const state = getState();
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
'method': METHOD_CAPTURE_WITH_EXTRAS,
|
||||
...state,
|
||||
}, () => {
|
||||
console.log("[popup] captured!");
|
||||
});
|
||||
window.submitted = true;
|
||||
window.close();
|
||||
}
|
||||
|
||||
// $FlowFixMe
|
||||
function ctrlEnterSubmit(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
submitComment();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// https://stackoverflow.com/a/6003829/706389
|
||||
function moveCaretToEnd(el) {
|
||||
if (typeof el.selectionStart == "number") {
|
||||
el.selectionStart = el.selectionEnd = el.value.length;
|
||||
} else if (typeof el.createTextRange != "undefined") {
|
||||
el.focus();
|
||||
var range = el.createTextRange();
|
||||
range.collapse(false);
|
||||
range.select();
|
||||
}
|
||||
}
|
||||
|
||||
function setupPage () {
|
||||
const comment = getComment();
|
||||
comment.focus();
|
||||
comment.addEventListener('keydown', ctrlEnterSubmit);
|
||||
|
||||
const tags = getTags();
|
||||
tags.addEventListener('keydown', ctrlEnterSubmit);
|
||||
tags.addEventListener('focus', () => moveCaretToEnd(tags)); // to put cursor to the end of tags when tabbed
|
||||
|
||||
getButton().addEventListener('click', submitComment);
|
||||
|
||||
window.submitted = false;
|
||||
|
||||
const state = load_state();
|
||||
restoreState(state);
|
||||
save_state(null); // clean
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', setupPage);
|
||||
|
||||
|
||||
window.addEventListener('unload', () => {
|
||||
if (!window.submitted) {
|
||||
save_state(getState());
|
||||
}
|
||||
}, true);
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"name": "<webpack managed>",
|
||||
"version": "<webpack managed>",
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"persistent": false
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html"
|
||||
},
|
||||
"browser_action": {
|
||||
"default_icon": "unicorn.png",
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "Capture page, with extra information"
|
||||
},
|
||||
"commands": {
|
||||
"capture-simple": {
|
||||
"description": "Quick capture: url, title and selection"
|
||||
},
|
||||
"_execute_browser_action": {
|
||||
"description": "Capture page, with extra information"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"128": "unicorn.png"
|
||||
},
|
||||
"manifest_version": 2
|
||||
}
|
32
tox.ini
Normal file
32
tox.ini
Normal file
|
@ -0,0 +1,32 @@
|
|||
[tox]
|
||||
minversion = 3.5
|
||||
# relies on the correct version of Python installed
|
||||
envlist = tests,mypy
|
||||
# https://github.com/tox-dev/tox/issues/20#issuecomment-247788333
|
||||
# hack to prevent .tox from crapping to the project directory
|
||||
toxworkdir={env:TOXWORKDIR_BASE:}{toxinidir}/.tox
|
||||
|
||||
[testenv]
|
||||
passenv =
|
||||
# useful for tests to know they are running under ci
|
||||
CI
|
||||
CI_*
|
||||
# respect user's cache dirs to prevent tox from crapping into project dir
|
||||
MYPY_CACHE_DIR
|
||||
PYTHONPYCACHEPREFIX
|
||||
|
||||
[testenv:tests]
|
||||
commands =
|
||||
pip install -e .[testing]
|
||||
# posargs allow test filtering, e.g. tox ... -- -k test_name
|
||||
python -m pytest server --ignore server/test_with_browser.py {posargs}
|
||||
|
||||
[testenv:mypy]
|
||||
commands =
|
||||
pip install -e .[linting]
|
||||
python -m mypy --install-types --non-interactive \
|
||||
server \
|
||||
# txt report is a bit more convenient to view on CI
|
||||
--txt-report .coverage.mypy \
|
||||
--html-report .coverage.mypy \
|
||||
{posargs}
|
|
@ -1,140 +0,0 @@
|
|||
const webpack = require('webpack'),
|
||||
path = require('path'),
|
||||
CleanWebpackPlugin = require('clean-webpack-plugin'),
|
||||
CopyWebpackPlugin = require('copy-webpack-plugin'),
|
||||
WebpackExtensionManifestPlugin = require('webpack-extension-manifest-plugin');
|
||||
// TODO remove plugins from package.json
|
||||
|
||||
const T = {
|
||||
CHROME : 'chrome',
|
||||
FIREFOX: 'firefox',
|
||||
};
|
||||
|
||||
const env = {
|
||||
TARGET : process.env.TARGET, // TODO assert not null?
|
||||
RELEASE: process.env.RELEASE,
|
||||
};
|
||||
|
||||
const pkg = require('./package.json');
|
||||
const baseManifest = require('./src/manifest.json');
|
||||
|
||||
const release = env.RELEASE == 'YES' ? true : false;
|
||||
const dev = !release; // meh. maybe make up my mind?
|
||||
const target = env.TARGET;
|
||||
|
||||
const name = "grasp" + (dev ? ' [dev]' : '');
|
||||
|
||||
// Firefox wouldn't let you rebind its default shortcuts most of which use Shift
|
||||
// On the other hand, Chrome wouldn't let you use Alt
|
||||
const modifier = target === T.CHROME ? 'Shift' : 'Alt';
|
||||
|
||||
// ugh. declarative formats are shit.
|
||||
const commandsExtra = {
|
||||
"capture-simple": {
|
||||
"suggested_key": {
|
||||
"default": `Ctrl+${modifier}+C`,
|
||||
"mac": `Command+${modifier}+C`
|
||||
}
|
||||
},
|
||||
"_execute_browser_action": {
|
||||
"suggested_key": {
|
||||
"default": `Ctrl+${modifier}+Y`,
|
||||
"mac": `Command+${modifier}+Y`
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// TODO make permissions literate
|
||||
const permissions = [
|
||||
"storage",
|
||||
"notifications",
|
||||
"activeTab",
|
||||
"http://localhost/capture",
|
||||
"https://localhost/capture"
|
||||
];
|
||||
|
||||
|
||||
const manifestExtra = {
|
||||
name: name,
|
||||
version: pkg.version,
|
||||
description: pkg.description,
|
||||
permissions: permissions,
|
||||
commands: commandsExtra,
|
||||
optional_permissions: [
|
||||
"http://*/capture",
|
||||
"https://*/capture",
|
||||
],
|
||||
};
|
||||
|
||||
if (dev) {
|
||||
manifestExtra.content_security_policy = "script-src 'self' 'unsafe-eval'; object-src 'self'";
|
||||
}
|
||||
|
||||
if (target === T.CHROME) {
|
||||
manifestExtra.options_ui = {chrome_style: true};
|
||||
}
|
||||
if (target === T.FIREFOX) {
|
||||
manifestExtra.options_ui = {browser_style: true};
|
||||
manifestExtra.browser_action = {browser_style: true};
|
||||
}
|
||||
|
||||
const build_path = path.join(__dirname, "dist"); // TODO target??
|
||||
|
||||
const options = {
|
||||
mode: dev ? 'development' : 'production', // TODO map directly from MODE env var?
|
||||
optimization: {
|
||||
// https://webpack.js.org/configuration/optimization
|
||||
// don't think minimize worth it for such a tiny extension
|
||||
minimize: false
|
||||
},
|
||||
entry: {
|
||||
background : path.join(__dirname, "src", "js", "background"),
|
||||
popup : path.join(__dirname, "src", "js", "popup"),
|
||||
options_page: path.join(__dirname, "src", "js", "options_page"),
|
||||
},
|
||||
output: {
|
||||
path: build_path,
|
||||
filename: "[name].js",
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
loader: "style-loader!css-loader", // TODO different from promnesia??
|
||||
exclude: /node_modules/
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
loader: "html-loader",
|
||||
exclude: /node_modules/
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin([build_path + "/*"]),
|
||||
new CopyWebpackPlugin([
|
||||
{ from: 'src/img/*.png', flatten: true },
|
||||
{ from: 'src/*.html' , flatten: true },
|
||||
]),
|
||||
new WebpackExtensionManifestPlugin({
|
||||
config: {
|
||||
base: baseManifest,
|
||||
extend: manifestExtra,
|
||||
}
|
||||
}),
|
||||
]
|
||||
};
|
||||
|
||||
// TODO https://webpack.js.org/configuration/devtool
|
||||
if (dev) {
|
||||
options.devtool = "cheap-module-eval-source-map";
|
||||
}
|
||||
|
||||
module.exports = options;
|
Loading…
Reference in a new issue