diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 844b18f..0000000 --- a/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "presets": ["@babel/preset-env", "@babel/preset-flow"] -} diff --git a/.ci/release b/.ci/release new file mode 100755 index 0000000..0ec687f --- /dev/null +++ b/.ci/release @@ -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() diff --git a/.ci/run b/.ci/run new file mode 100755 index 0000000..303de20 --- /dev/null +++ b/.ci/run @@ -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 diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index a6e45d6..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..22e90af --- /dev/null +++ b/.github/workflows/main.yml @@ -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' diff --git a/README.md b/README.md index 1f3ca15..fc87696 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/TODO.org b/TODO.org index 03c922f..f44dd10 100644 --- a/TODO.org +++ b/TODO.org @@ -1,27 +1,43 @@ * TODO [#A] simple auth for server bit? -* TODO [#B] doesn't look like hotkey working unless you manually rebind.. wtf? - * TODO [#C] how to test it? try on different pages? https://www.blazemeter.com/blog/6-easy-steps-testing-your-chrome-extension-selenium looks tricky... * TODO [#C] some sort of delayed append logic? e.g. don't dump immediately and add as subitems? * TODO [#C] or, add menu to extension? - * TODO [#C] hmm. if template is specified on client site, it's easier to dispatch todo vs non-todo etc?.. -* TODO [#D] do not minfy for dev -* TODO [#D] make manifest templated so we don't have to hardcode actions +* CNCL not sure, maybe extension press should be default? control it via option? +:LOGBOOK: +- State "CNCL" from "TODO" [2022-12-29 Thu 17:41] +:END: -* TODO [#D] production build? -## Packing -After the development of your extension run the command +* DONE [#B] doesn't look like hotkey working unless you manually rebind.. wtf? +:LOGBOOK: +- State "DONE" from "TODO" [2022-12-29 Thu 17:41] +:END: -``` -$ NODE_ENV=production npm run build -``` -Now, the content of `build` folder will be the extension ready to be submitted to the Chrome Web Store. Just take a look at the [official guide](https://developer.chrome.com/webstore/publish) to more infos about publishing. +* DONE [#D] do not minfy for dev +:LOGBOOK: +- State "DONE" from "TODO" [2022-12-29 Thu 17:40] +:END: + +* DONE [#D] make manifest templated so we don't have to hardcode actions +:LOGBOOK: +- State "DONE" from "TODO" [2022-12-29 Thu 17:40] +:END: + +* DONE [#D] production build? +:LOGBOOK: +- State "DONE" from "TODO" [2022-12-29 Thu 17:40] +:END: +: ## Packing +: After the development of your extension run the command +: +: ``` +: $ NODE_ENV=production npm run build +: ``` +: Now, the content of `build` folder will be the extension ready to be submitted to the Chrome Web Store. Just take a look at the [official guide](https://developer.chrome.com/webstore/publish) to more infos about publishing. -* TODO not sure, maybe extension press should be default? control it via option? * DONE [#A] adjust permissions... I guess gonna have to make it include /capture endpoint? CLOSED: [2019-01-06 Sun 23:57] @@ -35,7 +51,11 @@ Now, the content of `build` folder will be the extension ready to be submitted t CLOSED: [2019-01-13 Sun 13:01] * DONE [#B] release somewhere? not sure if necessary CLOSED: [2019-01-07 Mon 23:57] -** TODO [#C] post on reddit? +** DONE [#C] post on reddit? +:LOGBOOK: +- State "DONE" from "TODO" [2022-12-29 Thu 17:40] +:END: + * DONE [#B] get rid of kython, run ci for python too? CLOSED: [2019-01-07 Mon 23:57] diff --git a/build b/build deleted file mode 100755 index 91fa6bc..0000000 --- a/build +++ /dev/null @@ -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.. diff --git a/ci/mypy b/ci/mypy deleted file mode 100755 index 2284e95..0000000 --- a/ci/mypy +++ /dev/null @@ -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 diff --git a/ci/pylint b/ci/pylint deleted file mode 100755 index a663c48..0000000 --- a/ci/pylint +++ /dev/null @@ -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 diff --git a/ci/pytest b/ci/pytest deleted file mode 100755 index 7622e71..0000000 --- a/ci/pytest +++ /dev/null @@ -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 diff --git a/extension/.ci/build b/extension/.ci/build new file mode 100755 index 0000000..9a1cbee --- /dev/null +++ b/extension/.ci/build @@ -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" diff --git a/.eslintrc.js b/extension/.eslintrc.js similarity index 69% rename from .eslintrc.js rename to extension/.eslintrc.js index 42f2c35..987c768 100644 --- a/.eslintrc.js +++ b/extension/.eslintrc.js @@ -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", -// ], -//}; diff --git a/.flowconfig b/extension/.flowconfig similarity index 96% rename from .flowconfig rename to extension/.flowconfig index 5aaad51..ccd1f48 100644 --- a/.flowconfig +++ b/extension/.flowconfig @@ -2,6 +2,7 @@ .*/node_modules/.* [include] +src/ [libs] node_modules/flow-interfaces-chrome/interfaces/ diff --git a/extension/babel.config.js b/extension/babel.config.js new file mode 100644 index 0000000..e34a262 --- /dev/null +++ b/extension/babel.config.js @@ -0,0 +1,10 @@ +const presets = [ + '@babel/preset-flow', +] +const plugins = [] + +// if (process.env["ENV"] === "prod") { +// plugins.push(...); +// } + +module.exports = { presets, plugins } diff --git a/extension/build b/extension/build new file mode 100755 index 0000000..f7fd1e4 --- /dev/null +++ b/extension/build @@ -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() diff --git a/extension/flow-typed/webextension-polyfill.js b/extension/flow-typed/webextension-polyfill.js new file mode 100644 index 0000000..3bd7ad2 --- /dev/null +++ b/extension/flow-typed/webextension-polyfill.js @@ -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 | Object | null) => Promise, + set: (items: Object) => Promise, +} + + +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, + request : (permissions: chrome$Permissions) => Promise, +} + + +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, + windowId?: number, + windowType?: chrome$WindowType + }): Promise>, + executeScript: ( + (( + tabId?: number, + details: { + allFrames?: boolean, + code?: string, + file?: string, + frameId?: number, + matchAboutBlank?: boolean, + runAt?: chrome$RunAt + }, + ) => Promise) & + (( + details: { + allFrames?: boolean, + code?: string, + file?: string, + frameId?: number, + matchAboutBlank?: boolean, + runAt?: chrome$RunAt + }, + ) => Promise) + ), +} + + +type ExecuteResult = { + result: any, +} + + +type browser$scripting = { + executeScript: ({ + target: {tabId?: number}, + func: () => void, + }) => Promise>, +} + + +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; +} diff --git a/extension/jest.config.js b/extension/jest.config.js new file mode 100644 index 0000000..8dbab0a --- /dev/null +++ b/extension/jest.config.js @@ -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: [ + // "" + // ], + + // 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, +}; diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..396ee32 --- /dev/null +++ b/extension/package.json @@ -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 ", + "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" +} diff --git a/extension/src/img/unicorn.png b/extension/src/img/unicorn.png new file mode 100644 index 0000000..1e97bbc Binary files /dev/null and b/extension/src/img/unicorn.png differ diff --git a/extension/src/js/background.js b/extension/src/js/background.js new file mode 100644 index 0000000..2f5c157 --- /dev/null +++ b/extension/src/js/background.js @@ -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?? + diff --git a/src/js/common.js b/extension/src/js/common.js similarity index 92% rename from src/js/common.js rename to extension/src/js/common.js index b1010a4..2defa37 100644 --- a/src/js/common.js +++ b/extension/src/js/common.js @@ -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): Promise { return new Promise((resolve, reject) => { + // $FlowFixMe const cbb = (...xxx) => { const err = chrome.runtime.lastError; if (err) { diff --git a/extension/src/js/options.js b/extension/src/js/options.js new file mode 100644 index 0000000..ced9f0d --- /dev/null +++ b/extension/src/js/options.js @@ -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 { + const res = await browser.storage.local.get(null) + return {...defaultOptions(), ...res} +} + + +export async function setOptions(opts: Options): Promise { + console.log('Saving %o', opts) + await browser.storage.local.set(opts) +} diff --git a/extension/src/js/options_page.js b/extension/src/js/options_page.js new file mode 100644 index 0000000..3151f46 --- /dev/null +++ b/extension/src/js/options_page.js @@ -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) diff --git a/src/js/permissions.js b/extension/src/js/permissions.js similarity index 79% rename from src/js/permissions.js rename to extension/src/js/permissions.js index 10f4a99..12956b8 100644 --- a/src/js/permissions.js +++ b/extension/src/js/permissions.js @@ -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 { const perms = { origins: [ urlForPermissionsCheck(endpoint), ], - }; - return chromePermissionsContains(perms); + } + return browser.permissions.contains(perms) } -export function ensurePermissions(endpoint: string) { + +export function ensurePermissions(endpoint: string): Promise { 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??? } +*/ diff --git a/extension/src/js/popup.js b/extension/src/js/popup.js new file mode 100644 index 0000000..615c0ee --- /dev/null +++ b/extension/src/js/popup.js @@ -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()) + } + } +}) diff --git a/extension/src/manifest.json b/extension/src/manifest.json new file mode 100644 index 0000000..af2e8b7 --- /dev/null +++ b/extension/src/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "", + "version": "", + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "icons": { + "128": "img/unicorn.png" + } +} diff --git a/src/options.html b/extension/src/options.html similarity index 91% rename from src/options.html rename to extension/src/options.html index d669a1a..c4b0b52 100644 --- a/src/options.html +++ b/extension/src/options.html @@ -2,6 +2,7 @@ +