Compare commits

...

33 commits

Author SHA1 Message Date
Anish Lakhwara e4830b41be Merge remote-tracking branch 'upstream/master' 2023-04-27 13:54:28 +10:00
Dima Gerasimov 60bdc0ae15 more attempts for v3 and v2 simultaneous support
so far v3 works in firefox
but doesn't work with chrome, for some reason it refuses to import the webpack chunk
2022-12-30 04:01:09 +00:00
Dima Gerasimov 5ec8bcb822 configure webpack to split vendor libraries in a separate chunk
also generally makes it somewhat cleaner import-wise
2022-12-30 01:51:32 +00:00
Dima Gerasimov 91400126ae close some old todo items 2022-12-29 22:13:23 +00:00
Dima Gerasimov 11a10f1aa6 add sendResponse call to onMessage callback
this prevents phantom errors in popup logs
2022-12-29 22:13:23 +00:00
Dima Gerasimov 879540aee6 extension v0.7.1 2022-12-29 17:37:44 +00:00
Dima Gerasimov fc85a03a42 switch scripting.executeScript to promises, add types 2022-12-28 21:14:14 +00:00
Dima Gerasimov 32fb5ce108 final changes for manifest v3 -- seems that everything works now both for v2 and v3 2022-12-28 21:14:14 +00:00
Dima Gerasimov 4f0fbe9624 extension version 0.7.0 2022-12-28 19:24:13 +00:00
Dima Gerasimov 14f3addc07 ugh, somehow forgot to set open_in_tab to true before 2022-12-28 02:58:15 +00:00
Dima Gerasimov eca07d97a7 more preparation for supporting both manifest v2 and v3 2022-12-28 02:58:15 +00:00
Dima Gerasimov 16bd3441df update icon to 128x128 so webext linter stops complaining 2022-12-28 01:35:36 +00:00
Dima Gerasimov 69019b2df4 use browserlist instead of hardcoded babel presets 2022-12-28 01:35:36 +00:00
Dima Gerasimov ea8d270a9f fix alert after saving settings on firefox and chrome 2022-12-28 01:35:36 +00:00
Dima Gerasimov d5a0c35848 rewrite main capture function to use polyfill 2022-12-28 01:35:36 +00:00
Dima Gerasimov 0fb2e42fe9 use polyfill for permissions 2022-12-28 01:35:36 +00:00
Dima Gerasimov f906728bf8 user browser polyfill with Promise for options access 2022-12-28 01:35:36 +00:00
Dima Gerasimov 8dc554fd61 release extension v0.6.9 2022-12-27 14:37:21 +00:00
Dima Gerasimov a9ef5bd86a extension: fix for deprecated window unload listener, it doesn't always trigger anymore
+ some minor refactoring/renaming
2022-12-27 14:32:26 +00:00
Dima Gerasimov 709220eb10 build script: add watch mode 2022-12-27 14:32:26 +00:00
Dima Gerasimov c1acbc114b add extension ids for firefox and chrome 2022-12-24 21:20:33 +00:00
Dima Gerasimov 7dcd827704 bump extension version 2022-12-24 16:15:26 +00:00
Dima Gerasimov 11cd3b3f65 extension: some 'cosmetic' preparations for manifest v3
tested on chromium/firefox stable
2022-12-24 05:41:19 +00:00
Dima Gerasimov 5bdd5116e7 switch makeCaptureRequest to fetch api in preparation for manifest v3 2022-12-24 05:41:19 +00:00
Dima Gerasimov 20022db1f6 mac: allow using Shift-Enter to complete capture 2022-12-24 05:41:19 +00:00
Dima Gerasimov 83e7de9be9 extension: simplify webpack config a bit, remove some legacy, update dev packages 2022-12-24 05:41:19 +00:00
Dima Gerasimov ddb5fe5c31 extension: update webpack/babel/eslint/flow
mostly borrowed version from promnesia for now
2022-12-23 21:34:16 +00:00
Dima Gerasimov 4127e3de64 extension: move to a separate dir, update build scripts etc
borrowed build scritps etc from promnesia
2022-12-23 21:34:16 +00:00
Dima Gerasimov b57c787774 macos: support setup for launchd
should resolve https://github.com/karlicoss/grasp/issues/32
2022-12-21 18:48:52 +00:00
Dima Gerasimov 417a02ba6f ci: bring up to date with pymplate, setup github actions 2022-12-21 02:17:46 +00:00
Dima Gerasimov e019265aa1 org_tools: support escaping percents in templates via \%
also add a regression test

addresses
https://github.com/karlicoss/grasp/issues/35
https://github.com/karlicoss/grasp/pull/43

Thanks @Chickensoupwithrice for spotting and fix attempt
2022-12-21 02:17:46 +00:00
Dima Gerasimov 4375365415 org_tools: add a proper test comparing default template output 2022-12-21 02:17:46 +00:00
Dima Gerasimov 6ab099a330 server test: use requests instead of httpie 2022-12-21 02:17:46 +00:00
46 changed files with 1704 additions and 870 deletions

View file

@ -1,3 +0,0 @@
{
"presets": ["@babel/preset-env", "@babel/preset-flow"]
}

66
.ci/release Executable file
View 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
View 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

View file

@ -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
View 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'

View file

@ -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, 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. possibly selected text, additional comments or tags and adds it into your [Org Mode](https://orgmode.org/) file.

View file

@ -1,27 +1,43 @@
* TODO [#A] simple auth for server bit? * 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? * TODO [#C] how to test it? try on different pages?
https://www.blazemeter.com/blog/6-easy-steps-testing-your-chrome-extension-selenium https://www.blazemeter.com/blog/6-easy-steps-testing-your-chrome-extension-selenium
looks tricky... 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] hmm. if template is specified on client site, it's easier to dispatch todo vs non-todo etc?.. * 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 * CNCL not sure, maybe extension press should be default? control it via option?
* TODO [#D] make manifest templated so we don't have to hardcode actions :LOGBOOK:
- State "CNCL" from "TODO" [2022-12-29 Thu 17:41]
:END:
* TODO [#D] production build? * DONE [#B] doesn't look like hotkey working unless you manually rebind.. wtf?
## Packing :LOGBOOK:
After the development of your extension run the command - State "DONE" from "TODO" [2022-12-29 Thu 17:41]
:END:
``` * DONE [#D] do not minfy for dev
$ NODE_ENV=production npm run build :LOGBOOK:
``` - State "DONE" from "TODO" [2022-12-29 Thu 17:40]
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. :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? * DONE [#A] adjust permissions... I guess gonna have to make it include /capture endpoint?
CLOSED: [2019-01-06 Sun 23:57] 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] CLOSED: [2019-01-13 Sun 13:01]
* DONE [#B] release somewhere? not sure if necessary * DONE [#B] release somewhere? not sure if necessary
CLOSED: [2019-01-07 Mon 23:57] 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? * DONE [#B] get rid of kython, run ci for python too?
CLOSED: [2019-01-07 Mon 23:57] CLOSED: [2019-01-07 Mon 23:57]

74
build
View file

@ -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..

View file

@ -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

View file

@ -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

View file

@ -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
View 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"

View file

@ -1,5 +1,5 @@
module.exports = { module.exports = {
"parser": "babel-eslint", "parser": "@babel/eslint-parser",
"plugins": [ "plugins": [
"flowtype" "flowtype"
], ],
@ -27,12 +27,12 @@ module.exports = {
"indent": "off", "indent": "off",
"comma-dangle": "off", "comma-dangle": "off",
"no-console": "off", "no-console": "off",
"no-inner-declarations": "off",
"flowtype/space-before-type-colon": "off",
"no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}],
} }
// TODO use flow? // TODO use flow?
}; };
//module.exports = {
// // "extends": "google",
// 'extends': [
// "plugin:react/recommended",
// ],
//};

View file

@ -2,6 +2,7 @@
.*/node_modules/.* .*/node_modules/.*
[include] [include]
src/
[libs] [libs]
node_modules/flow-interfaces-chrome/interfaces/ node_modules/flow-interfaces-chrome/interfaces/

10
extension/babel.config.js Normal file
View 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
View 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()

View 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
View 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
View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View 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??

View file

@ -9,13 +9,14 @@ export function showNotification(text: string, priority: number=0) {
'title': "grasp", 'title': "grasp",
'message': text, 'message': text,
'priority': priority, 'priority': priority,
'iconUrl': 'unicorn.png', 'iconUrl': 'img/unicorn.png',
}); });
} }
// $FlowFixMe // $FlowFixMe
export function awrap(fn, ...args: Array<any>): Promise<any> { export function awrap(fn, ...args: Array<any>): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// $FlowFixMe
const cbb = (...xxx) => { const cbb = (...xxx) => {
const err = chrome.runtime.lastError; const err = chrome.runtime.lastError;
if (err) { if (err) {

View 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)
}

View 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)

View file

@ -1,15 +1,5 @@
/* @flow */ /* @flow */
import {showNotification, awrap} from './common'; import browser from "webextension-polyfill"
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);
}
function urlForPermissionsCheck(url: string): string { function urlForPermissionsCheck(url: string): string {
@ -23,23 +13,24 @@ function urlForPermissionsCheck(url: string): string {
return u.toString(); return u.toString();
} }
export function hasPermissions(endpoint: string) { export async function hasPermissions(endpoint: string): Promise<boolean> {
const perms = { const perms = {
origins: [ origins: [
urlForPermissionsCheck(endpoint), urlForPermissionsCheck(endpoint),
], ],
}; }
return chromePermissionsContains(perms); return browser.permissions.contains(perms)
} }
export function ensurePermissions(endpoint: string) {
export function ensurePermissions(endpoint: string): Promise<boolean> {
const perms = { const perms = {
origins: [ origins: [
urlForPermissionsCheck(endpoint), urlForPermissionsCheck(endpoint),
], ],
}; }
// shouldn't prompt if we already have the permission // 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... // 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... // TODO fuck. doesn't seem to work for Firefox at all...
// just getting Error: "An unexpected error occurred" background.bundle.js // just getting Error: "An unexpected error occurred" background.bundle.js
// seems like this bug https://bugzilla.mozilla.org/show_bug.cgi?id=1382953 // seems like this bug https://bugzilla.mozilla.org/show_bug.cgi?id=1382953
/*
function ensurePermissions_old(cb: () => void) { // eslint-disable-line no-unused-vars function ensurePermissions_old(cb: () => void) { // eslint-disable-line no-unused-vars
const back = chrome.extension.getBackgroundPage().console; const back = chrome.extension.getBackgroundPage().console;
@ -82,3 +74,4 @@ function ensurePermissions_old(cb: () => void) { // eslint-disable-line no-unuse
}); });
// TODO improve message somehow??? // TODO improve message somehow???
} }
*/

161
extension/src/js/popup.js Normal file
View 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())
}
}
})

View 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"
}
}

View file

@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<script type="text/javascript" src="webextension-polyfill.js"></script>
<script type="text/javascript" src="options_page.js"/></script> <script type="text/javascript" src="options_page.js"/></script>
<style> <style>
input:invalid { input:invalid {

View file

@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<script type="text/javascript" src="webextension-polyfill.js"></script>
<script type="text/javascript" src="popup.js"></script> <script type="text/javascript" src="popup.js"></script>
</head> </head>
<body> <body>

241
extension/webpack.config.js Normal file
View 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
View 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

View file

@ -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
View file

@ -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
View 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

View file

@ -23,11 +23,11 @@ def empty(s) -> bool:
# TODO put template in config?? # TODO put template in config??
class Config: class Config:
@staticmethod @staticmethod
def format_selection(selection: str) -> List[str]: def format_selection(selection: str) -> List[str]: # type: ignore[empty-body]
... ...
@staticmethod @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], tags: List[str],
org_template: str, org_template: str,
config: Optional[Config] = None, 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. 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. 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{\2}', org_template)
py_template = re.sub(r'[^\\]%[:]?([?\w]+)', r'{\1}', 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_date = date2org(NOW)
org_datetime = datetime2org(NOW) org_datetime = datetime2org(NOW)
@ -104,9 +107,8 @@ You can look at `test_templates` for some specific examples.
) )
return res return res
# TODO escaping?
# just checks it's not crashing for now def test_templates() -> None:
def test_templates():
url = 'https://whatever' url = 'https://whatever'
title = 'hello' title = 'hello'
selection = """some selection = """some
@ -116,6 +118,7 @@ def test_templates():
comment = 'fafewfewf' comment = 'fafewfewf'
tags = ['aba', 'caba'] tags = ['aba', 'caba']
vimzettel_template = r'zettel \%title [%:description] testing'
# https://orgmode.org/guide/Capture-templates.html # https://orgmode.org/guide/Capture-templates.html
org_templates = [ org_templates = [
DEFAULT_TEMPLATE, DEFAULT_TEMPLATE,
@ -135,7 +138,9 @@ def test_templates():
%:selection %:selection
#+END_QUOTE #+END_QUOTE
""", """,
vimzettel_template,
] ]
# check they aren't crashing
for org_template in org_templates: for org_template in org_templates:
res = as_org( res = as_org(
url, url,
@ -146,3 +151,39 @@ def test_templates():
org_template=org_template 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

View file

@ -1,12 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import argparse
import shlex import os
from pathlib import Path 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 from grasp_server import setup_parser
SYSTEMD_CONFIG = """
SYSTEM = platform.system()
SYSTEMD_TEMPLATE = """
[Unit] [Unit]
Description=Grasp extension server conterpart Description=Grasp extension server conterpart
@ -17,7 +23,29 @@ WantedBy=default.target
ExecStart={server} {extra_args} ExecStart={server} {extra_args}
Type=simple Type=simple
Restart=always 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): def systemd(*args, method=check_call):
@ -25,28 +53,33 @@ def systemd(*args, method=check_call):
'systemctl', '--user', *args, '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' base_path = '~/.config/systemd/user'
if not Path(base_path).expanduser().is_dir(): if not Path(base_path).expanduser().is_dir():
Path(base_path).expanduser().mkdir(parents=True, exist_ok=True) Path(base_path).expanduser().mkdir(parents=True, exist_ok=True)
unit_name = args.unit_name unit_name = args.unit_name
out = Path(f'{base_path}/{unit_name}').expanduser() out = Path(f'{base_path}/{unit_name}.service').expanduser()
print(f"Writing systemd config to {out}:") print(f"Writing unit file to {out}")
server_bin = Path(__file__).parent.joinpath('grasp_server.py').absolute() 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... out.write_text(SYSTEMD_TEMPLATE.format(
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(
server=server_bin, server=server_bin,
extra_args=extra_args, extra_args=extra_args,
)) ))
@ -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") print(f"Something has gone wrong... you might want to use 'journalctl --user -u {unit_name}' to debug")
raise e 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 = 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) setup_parser(p)
args = p.parse_args() args = p.parse_args()
setup(args) if SYSTEM == 'Linux':
setup_systemd(args)
else: # assume macos
setup_launchd(args)
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -4,16 +4,10 @@ from subprocess import Popen, check_call
from time import sleep from time import sleep
# ugh. import requests
@contextmanager @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): def grasp_test_server(capture_file: Path, port: str, template=None):
server = str((Path(__file__).parent / 'grasp_server.py').absolute()) server = str((Path(__file__).parent / 'grasp_server.py').absolute())
cmdline = [ cmdline = [
@ -21,23 +15,29 @@ def grasp_test_server(capture_file: Path, port: str, template=None):
] ]
if template is not None: if template is not None:
cmdline.extend(['--template', template]) 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' cfile = tmp_path / 'test-capture.org'
PORT = '17890' PORT = '17890'
def send(url: str, title: str): def send(url: str, title: str):
# TODO eh, not sure if there is anything easier than httpie r = requests.post(
check_call([ f'http://localhost:{PORT}',
'http', '--ignore-stdin', 'POST', f'http://localhost:{PORT}', json = {
f'url={url}', 'url': url,
f'title={title}', 'title': title,
'selection=null', 'selection': None,
'comment=null', 'comment': None,
'tag_str=null', 'tag_str': None,
]) },
)
assert r.status_code == 200, r
with grasp_test_server(capture_file=cfile, port=PORT): with grasp_test_server(capture_file=cfile, port=PORT):

57
setup.py Normal file
View 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

View file

@ -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??

View file

@ -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);
});
}

View file

@ -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);

View file

@ -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);

View file

@ -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
View 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}

View file

@ -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;