Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Anish Lakhwara 2023-04-27 13:54:28 +10:00
commit e4830b41be
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,
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 [#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
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 = {
"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",
// ],
//};

View file

@ -2,6 +2,7 @@
.*/node_modules/.*
[include]
src/
[libs]
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",
'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) {

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 */
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
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">
<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 {

View file

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

View file

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

View file

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