├── .eslintrc.js ├── .github └── workflows │ ├── code-quality.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.toml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── makefile ├── package.json ├── src ├── assets │ ├── icon-128.png │ ├── icon-256.png │ └── manifest.json ├── backend.ts ├── components │ ├── badge.ts │ ├── bookmark-form.tsx │ ├── bookmark.tsx │ ├── button.tsx │ ├── error-popup.tsx │ ├── highlight-markup.tsx │ ├── icon-button.ts │ ├── list-item.tsx │ ├── load-more-bookmarks.tsx │ ├── modal.tsx │ ├── onboarding.tsx │ ├── tag.tsx │ ├── text-input.tsx │ ├── title-menu.tsx │ └── tooltip.tsx ├── containers │ ├── bookmark-add-form.tsx │ ├── bookmark-delete-form.tsx │ ├── bookmark-edit-form.tsx │ ├── bookmarks-list.tsx │ ├── error-messages.tsx │ ├── open-all-bookmarks-confirmation.tsx │ ├── search-controls.tsx │ ├── search.tsx │ ├── staged-group-bookmark-edit-form.tsx │ ├── staged-group-bookmarks-list.tsx │ └── staged-groups-list.tsx ├── content.html ├── content.tsx ├── global.d.ts ├── hooks │ └── listen-to-keydown.ts ├── modules │ ├── array.test.ts │ ├── array.ts │ ├── badge.ts │ ├── bookmarklet.ts │ ├── bookmarks.test.ts │ ├── bookmarks.ts │ ├── buku.ts │ ├── command.ts │ ├── comms │ │ ├── browser.ts │ │ ├── isomorphic.ts │ │ └── native.ts │ ├── compare-urls.test.ts │ ├── compare-urls.ts │ ├── config.ts │ ├── connected-mount.tsx │ ├── context.ts │ ├── eitherOption.ts │ ├── eq.ts │ ├── error.ts │ ├── fp.ts │ ├── io.ts │ ├── omnibox.ts │ ├── optionTuple.ts │ ├── parse-search-input.test.ts │ ├── parse-search-input.ts │ ├── prism.ts │ ├── record.ts │ ├── redux.ts │ ├── regex.ts │ ├── scroll-window.ts │ ├── semantic-versioning.test.ts │ ├── semantic-versioning.ts │ ├── settings.ts │ ├── sleep.ts │ ├── staged-groups.ts │ ├── string.test.ts │ ├── string.ts │ ├── sync.ts │ ├── terminology.ts │ ├── tuple.ts │ ├── url.test.ts │ ├── url.ts │ ├── uuid.test.ts │ └── uuid.ts ├── options.html ├── options.tsx ├── pages │ └── options.tsx ├── store │ ├── bookmarks │ │ ├── actions.ts │ │ ├── epics.ts │ │ ├── reducers.ts │ │ └── types.ts │ ├── browser │ │ ├── actions.ts │ │ ├── epics.ts │ │ ├── reducers.ts │ │ └── types.ts │ ├── epics.ts │ ├── index.ts │ ├── input │ │ ├── actions.ts │ │ ├── reducers.ts │ │ └── types.ts │ ├── notices │ │ ├── actions.ts │ │ ├── epics.ts │ │ ├── reducers.ts │ │ └── types.ts │ ├── selectors.ts │ └── user │ │ ├── actions.ts │ │ ├── reducers.ts │ │ └── types.ts └── styles.tsx ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | plugins: ["@typescript-eslint", "react", "jest"], 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/eslint-recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 9 | "plugin:react/recommended", 10 | ], 11 | parserOptions: { 12 | project: "./tsconfig.json", 13 | ecmaVersion: 2020, 14 | }, 15 | rules: { 16 | "@typescript-eslint/indent": [2, "tab"], 17 | "@typescript-eslint/no-use-before-define": 0, 18 | "@typescript-eslint/unbound-method": 0, 19 | "@typescript-eslint/array-type": [ 20 | 1, 21 | { default: "generic", readonly: "generic" }, 22 | ], 23 | "@typescript-eslint/no-unused-vars": [1, { argsIgnorePattern: "^_" }], 24 | "@typescript-eslint/no-floating-promises": 0, 25 | "@typescript-eslint/indent": 0, 26 | "react/prop-types": 0, 27 | "react/display-name": 0, 28 | "no-console": 1, 29 | }, 30 | env: { 31 | es6: true, 32 | browser: true, 33 | jest: true, 34 | "jest/globals": true, 35 | }, 36 | globals: { 37 | page: true, 38 | browser: true, 39 | context: true, 40 | }, 41 | settings: { 42 | react: { 43 | version: "detect", 44 | }, 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/code-quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality 2 | on: [push] 3 | jobs: 4 | code-quality: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Cache Yarn dependencies 9 | uses: actions/cache@v2 10 | env: 11 | cache-name: cache-yarn 12 | with: 13 | path: ~/.cache/yarn/ 14 | key: yarn-${{ hashFiles('./yarn.lock') }} 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: "15.x" 19 | - name: Install dependencies 20 | run: yarn install --frozen-lockfile 21 | - name: Typecheck 22 | run: yarn typecheck 23 | - name: Lint 24 | run: yarn lint 25 | - name: Test 26 | run: yarn test 27 | - name: Check formatting 28 | run: yarn fmt-check 29 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: ["v[0-9].[0-9].[0-9].[0-9]"] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/cache@v2 13 | env: 14 | cache-name: cache-yarn 15 | with: 16 | path: ~/.cache/yarn/ 17 | key: yarn-${{ hashFiles('./yarn.lock') }} 18 | - name: Build & prepare 19 | run: make webext 20 | - name: Publish 21 | uses: svenstaro/upload-release-action@v2 22 | with: 23 | repo_token: ${{ secrets.GITHUB_TOKEN }} 24 | file: release/webext.zip 25 | tag: ${{ github.ref }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .build/ 4 | .cache/ 5 | release/ 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .build/ 4 | .cache/ 5 | release/ 6 | -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | semi = false 2 | arrowParens = "avoid" 3 | trailingComma = "all" 4 | 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | This project is versioned according to its compatibility with the [host](https://github.com/samhh/bukubrow-host) from v4 onwards. 4 | 5 | ## [5.0.3.0] - 2021-05-02 6 | 7 | ### Added 8 | 9 | - Omnibox (address bar suggestions) support. 10 | 11 | ## [5.0.2.1] - 2021-03-27 12 | 13 | ### Changed 14 | 15 | - Fix bookmarks list keyboard scroll navigation. 16 | 17 | ## [5.0.2.0] - 2020-02-14 18 | 19 | ### Added 20 | 21 | - Global configurable hotkeys. 22 | - Onboarding flow when no communication can be achieved with the host. 23 | 24 | ### Changed 25 | 26 | - Stop closing popup after executing a bookmarklet. 27 | - Improved UI for bookmarklets. 28 | 29 | ## [5.0.1.0] - 2020-02-09 30 | 31 | ### Added 32 | 33 | - Support for bookmarklets. This is why the `activeTab` permission is being newly requested. 34 | 35 | ## [5.0.0.3] - 2020-02-08 36 | 37 | ### Changed 38 | 39 | - Fix opening multiple bookmarks in Chromium-based browsers. 40 | 41 | ## [5.0.0.2] - 2019-06-15 42 | 43 | ### Changed 44 | 45 | - Fix race condition in Firefox where browser popup will close before it can open requested bookmarks. 46 | 47 | ## [5.0.0.1] - 2019-06-15 48 | 49 | ### Added 50 | 51 | - Support for very large Buku databases that serialise to over 1MB in size. 52 | - Settings option to configure the WebExtension's badge. 53 | 54 | ### Changed 55 | 56 | - Bookmarks will now attempt to open in your active tab if it's a new tab page. 57 | 58 | ## [4.0.0.1] - 2019-05-04 59 | 60 | ### Changed 61 | 62 | - Versioning has been changed to move in tandem with the [host](https://github.com/SamHH/bukubrow-host). 63 | 64 | ## [3.0.0] - 2019-04-28 65 | 66 | ### Added 67 | 68 | - Send browser tabs to a "staging area" via context menu, wherein they can be easily edited and added to Buku(brow). 69 | 70 | ### Changed 71 | 72 | - Removed fetch bookmarks button. They are now implictly fetched from the binary frequently - on load and upon any change. 73 | - Improved keyboard navigation in bookmark form. 74 | - Improved overflow behaviour of text in listed bookmarks. 75 | - Fixed ability to try and add a bookmark without communication with the binary having been achieved. 76 | 77 | ## [2.6.0] - 2019-03-03 78 | 79 | ### Added 80 | 81 | - Browser action (WebExtension toolbar icon) displays a badge based upon whether a bookmark with the exact URL or domain of the active tab is present, differentiated by colour. 82 | - Pin bookmarks whose URLs match the active tab at the top of the bookmarks list. 83 | 84 | ### Changed 85 | 86 | - Fixed regression that broke padding for bookmarks with no description. 87 | 88 | ## [2.5.5] - 2019-02-24 89 | 90 | ### Changed 91 | 92 | - Fixed regression that mislabelled add/edit bookmark modals. 93 | - Fixed button tooltip positioning in bookmarks. 94 | 95 | ## [2.5.4] - 2019-02-12 96 | 97 | ### Changed 98 | 99 | - Fixed regression that prevented new bookmarks from being saved in some browsers. 100 | 101 | ## [2.5.3] - 2019-02-12 102 | 103 | ### Changed 104 | 105 | - Fixed regression that prevented search input from auto-focusing on load. 106 | 107 | ## [2.5.2] - 2019-02-11 108 | 109 | ### Changed 110 | 111 | - Fixed regression that prevented bookmark opening. 112 | 113 | ## [2.5.1] - 2019-02-09 114 | 115 | ### Changed 116 | 117 | - Fixed results text not being highlighted if the search terms overlap. 118 | - Fixed tutorial not closing upon successfully fetching bookmarks on first load. 119 | - Disabled unusable action buttons during tutorial. 120 | - Disabled text input completely during tutorial. 121 | 122 | ## [2.5.0] - 2019-01-30 123 | 124 | ### Added 125 | 126 | - Input filtering with tokens in the search input. 127 | - Hotkeys for search control bar buttons. 128 | - Hotkey for focusing search input. 129 | - Confirmation modal when opening all bookmarks. 130 | 131 | ### Changed 132 | 133 | - Fixed button tooltips going crazy when buttons are active. 134 | - Hopefully fixed some sporadic issues, usually focused around tags. 135 | - Improved error messages. 136 | 137 | ## [2.4.2] - 2018-10-14 138 | 139 | ### Changed 140 | 141 | - Fixed tags not adhering correctly to Buku's schema. 142 | - Fixed some styling issues in Firefox. 143 | 144 | ## [2.4.1] - 2018-07-04 145 | 146 | ### Changed 147 | 148 | - Fixed some fields being immutable for new bookmarks. 149 | 150 | ## [2.4.0] - 2018-06-21 151 | 152 | ### Added 153 | 154 | - Autofill active tab title for new bookmark form like URL was previously. 155 | - Number of matches is now displayed in tooltip when hovering "Open All Matches" button. 156 | 157 | ### Changed 158 | 159 | - Browser action shortcut to Ctrl+Shift+B. 160 | - Added a minimum height to entire UI frame to fix bookmark form being unusable. 161 | 162 | ## [2.3.0] - 2018-04-19 163 | 164 | ### Added 165 | 166 | - Functionality to add a bookmark. 167 | - Functionality to edit a bookmark. 168 | - Functionality to delete a bookmark. 169 | 170 | ## [2.2.0] - 2018-01-14 171 | 172 | ### Added 173 | 174 | - Tooltip to buttons in search bar. (Thanks [andipabst](https://github.com/andipabst)!) 175 | 176 | ### Changed 177 | 178 | - Fixed various styling inconsistencies in Firefox. 179 | 180 | ## [2.1.0] - 2017-12-14 181 | 182 | ### Added 183 | 184 | - Button to open all bookmarks in search bar. 185 | 186 | ### Changed 187 | 188 | - Search bar to be fixed to the top of the UI. 189 | - UI to scroll with you if you use keyboard navigation and start scrolling off-screen. 190 | 191 | ## [2.0.0] - 2017-12-13 192 | 193 | ### Changed 194 | 195 | - Rewritten binary to make new features possible. 196 | 197 | ## [1.1.1] - 2017-05-31 198 | 199 | ### Changed 200 | 201 | - Don't change case sensitivity of matched text when filtering bookmarks. 202 | - New refresh icon and accompanying rotate animation. 203 | - Fixed multiple severe issues unique to Firefox: 204 | - Startup error. 205 | - Not autofocusing. 206 | - No preferences. 207 | - Popup window not closing upon opening a bookmark. 208 | - Styling incl/ the refresh icon. 209 | 210 | ## [1.1.0] - 2017-05-08 211 | 212 | ### Added 213 | 214 | - Much improved UI layout with more useful information. 215 | - Highlight the filter text wherever it matches. 216 | - User-visible error messages. 217 | - Basic tutorial message for new users. 218 | 219 | ### Changed 220 | 221 | - Filtering will now filter based upon four fields, in this order: name, tags, URL, description. 222 | - Fixed URL validity checker. 223 | - Minimum Chrome & Firefox versions (for async/await). 224 | - Misc bugs and QOL improvements. 225 | 226 | ## [1.0.2] - 2017-04-28 227 | 228 | ### Added 229 | 230 | - Ability to select bookmarks with the up/down arrow keys. 231 | - Options page with a light/dark theme option. 232 | - Icon to extension list. 233 | - Changelog. 234 | 235 | ### Changed 236 | 237 | - Minimum Chrome version (for CSS Custom Properties). 238 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bukubrow 2 | 3 | Bukubrow is a WebExtension for [Buku](https://github.com/jarun/Buku), a command-line bookmark manager. 4 | 5 | - Display, open, add, edit, and delete bookmarks 6 | - Quickly search for and open bookmarks from the address bar 7 | - Automatically save open tabs to the _staging area_ from the context menu, from which they can be optionally edited and saved 8 | - Filter bookmarks with any of the following syntax: `:url`, `>description`, `#tag`, `*wildcard` 9 | - Bookmarklet (arbitrary JavaScript scripting) support, simply prepend your "URL" with `javascript:`, for example: `javascript:document.body.style.background = 'red'` 10 | - Custom hotkeys are available - please read the instructions [here](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/commands#updating_shortcuts) to customise them in your browser 11 | 12 | ## Prerequisites 13 | 14 | A corresponding [native host](https://github.com/SamHH/bukubrow-host) is used to interface with your Buku database. Communication between the host and the browser extension is handled via [native messaging](https://developer.chrome.com/extensions/nativeMessaging). 15 | 16 | - Buku 17 | - Bukubrow Host 18 | - Supported browser: Firefox, Chrome, or Chromium 19 | - _If building the WebExtension_: 20 | - Node 21 | - Yarn 22 | 23 | ## Installation 24 | 25 | Installing the host and registering it with your browser is required to allow the browser extension to talk to Buku. 26 | 27 | Install the WebExtension from the relevant addon store. 28 | 29 | - Chrome/Chromium: https://chrome.google.com/webstore/detail/bukubrow/ghniladkapjacfajiooekgkfopkjblpn 30 | - Firefox: https://addons.mozilla.org/en-US/firefox/addon/bukubrow/ 31 | 32 | Alternatively, you can build the WebExtension manually as follows: 33 | 34 | 1. Clone the repo. 35 | 2. Run `make webext`. Your zip file will be located within the `./release/` directory. This zip file is the exact structure expected by all compatible browsers. 36 | 3. Load the extension in your browser. Please refer to the browser documentation. 37 | 38 | ## Contributing 39 | 40 | The WebExtension is written in strict TypeScript, utilising React for rendering and Redux with thunks for state management, and makes heavy use of the functional library `fp-ts` for ADT-driven data management and enhanced type safety. Yarn is used for dependency management and task running. Data is fetched from the host via native messaging. 41 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const tsconfig = require("./tsconfig.json") 2 | const { pathsToModuleNameMapper } = require("ts-jest/utils") 3 | 4 | module.exports = { 5 | transform: { 6 | "\\.tsx?$": "ts-jest", 7 | }, 8 | testRegex: "^.+\\.test.tsx?$", 9 | moduleFileExtensions: ["js", "ts", "tsx"], 10 | moduleNameMapper: pathsToModuleNameMapper(tsconfig.compilerOptions.paths, { 11 | prefix: "/src/", 12 | }), 13 | } 14 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | # The use of `${MAKE} target` is to allow the reuse of targets and also ensure 2 | # explicit ordering 3 | 4 | SHELL := /usr/bin/env bash 5 | 6 | # Vars 7 | TEMP_BUILD_DIR = .build 8 | TEMP_CLONE_DIR = .clone 9 | RELEASE_DIR = release 10 | 11 | # Prepare temp and release dirs 12 | .PHONY: prepare 13 | prepare: 14 | mkdir -p $(TEMP_BUILD_DIR) $(TEMP_CLONE_DIR) $(RELEASE_DIR) 15 | 16 | # Remove temp dirs 17 | .PHONY: clean 18 | clean: 19 | rm -rf $(TEMP_BUILD_DIR) $(TEMP_CLONE_DIR) 20 | 21 | # Remove build and release dirs 22 | .PHONY: wipe 23 | wipe: 24 | ${MAKE} clean 25 | rm -rf $(RELEASE_DIR) 26 | 27 | # Wipe, and also remove node_modules/ and any cache directories 28 | .PHONY: nuke 29 | nuke: 30 | ${MAKE} wipe 31 | rm -rf node_modules/ .cache/ dist/ 32 | 33 | 34 | # Build WebExtension via Yarn and zip into release dir 35 | .PHONY: webext 36 | webext: 37 | ${MAKE} prepare 38 | yarn && yarn build 39 | cd dist && zip -r '../$(RELEASE_DIR)/webext' ./* 40 | ${MAKE} clean 41 | 42 | # Produce a zip of the source code for the Firefox Addon Store 43 | .PHONY: webext-src 44 | webext-src: 45 | ${MAKE} prepare 46 | git clone git@github.com:samhh/bukubrow-webext.git $(TEMP_CLONE_DIR) 47 | zip -r $(RELEASE_DIR)/bukubrow-src $(TEMP_CLONE_DIR)/* 48 | ${MAKE} clean 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bukubrow", 3 | "version": "5.0.3.0", 4 | "description": "WebExtension for Buku", 5 | "main": "webextension/backend.js", 6 | "scripts": { 7 | "prepare": "rm -rf ./dist/ && mkdir ./dist/ && cp ./src/assets/* ./dist/", 8 | "typecheck": "tsc --noEmit", 9 | "lint": "eslint ./src/ --ext ts,tsx", 10 | "dev": "yarn run prepare && parcel watch ./src/content.html ./src/options.html ./src/backend.ts", 11 | "build": "yarn run prepare && parcel build ./src/content.html ./src/options.html ./src/backend.ts", 12 | "test": "jest", 13 | "fmt": "prettier --write .", 14 | "fmt-check": "prettier --check ." 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/SamHH/bukubrow.git" 19 | }, 20 | "author": "Sam A. Horvath-Hunt (https://samhh.com)", 21 | "license": "GPL-3.0", 22 | "bugs": { 23 | "url": "https://github.com/SamHH/bukubrow/issues" 24 | }, 25 | "homepage": "https://github.com/SamHH/bukubrow", 26 | "browserslist": [ 27 | "last 2 Chrome versions", 28 | "last 2 Firefox versions" 29 | ], 30 | "dependencies": { 31 | "date-fns": "^2.6.0", 32 | "fp-ts": "^2.0.2", 33 | "fp-ts-contrib": "^0.1.6", 34 | "fp-ts-std": "^0.10.0", 35 | "io-ts": "^2.0.1", 36 | "io-ts-types": "^0.5.3", 37 | "monocle-ts": "^2.0.0", 38 | "newtype-ts": "^0.3.2", 39 | "react": "^17.0.2", 40 | "react-dom": "^17.0.2", 41 | "react-feather": "^2.0.3", 42 | "react-highlight-words": "^0.17.0", 43 | "react-redux": "^7.0.2", 44 | "redux": "^4.0.1", 45 | "redux-thunk": "^2.3.0", 46 | "remote-redux-devtools": "^0.5.16", 47 | "reselect": "^4.0.0", 48 | "styled-components": "^5.0.1", 49 | "styled-sanitize": "^1.1.1", 50 | "typesafe-actions": "^5.1.0", 51 | "webextension-polyfill-ts": "^0.25.0" 52 | }, 53 | "devDependencies": { 54 | "@types/jest": "^26.0.22", 55 | "@types/react": "^17.0.3", 56 | "@types/react-dom": "^17.0.3", 57 | "@types/react-highlight-words": "^0.16.0", 58 | "@types/react-redux": "^7.0.0", 59 | "@types/remote-redux-devtools": "^0.5.3", 60 | "@types/styled-components": "5.1.9", 61 | "@typescript-eslint/eslint-plugin": "^4.22.0", 62 | "@typescript-eslint/parser": "^4.22.0", 63 | "eslint": "^7.24.0", 64 | "eslint-plugin-jest": "^24.3.5", 65 | "eslint-plugin-react": "^7.12.4", 66 | "jest": "^26.6.3", 67 | "jest-webextension-mock": "^3.5.0", 68 | "parcel-bundler": "^1.12.4", 69 | "prettier": "^2.2.1", 70 | "ts-jest": "^26.5.4", 71 | "typescript": "^4.2.4" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/assets/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samhh/bukubrow-webext/467987a7866b984a70bab1d2e9625b14e446d711/src/assets/icon-128.png -------------------------------------------------------------------------------- /src/assets/icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samhh/bukubrow-webext/467987a7866b984a70bab1d2e9625b14e446d711/src/assets/icon-256.png -------------------------------------------------------------------------------- /src/assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Bukubrow", 3 | "version": "5.0.3.0", 4 | "manifest_version": 2, 5 | "description": "Synchronise your browser bookmarks with Buku", 6 | "icons": { 7 | "128": "icon-128.png", 8 | "256": "icon-256.png" 9 | }, 10 | "homepage_url": "https://github.com/samhh/bukubrow", 11 | "browser_action": { 12 | "default_icon": "icon-256.png", 13 | "default_popup": "content.html" 14 | }, 15 | "options_ui": { 16 | "page": "options.html" 17 | }, 18 | "background": { 19 | "scripts": ["backend.js"] 20 | }, 21 | "permissions": [ 22 | "nativeMessaging", 23 | "storage", 24 | "tabs", 25 | "contextMenus", 26 | "activeTab" 27 | ], 28 | "omnibox": { 29 | "keyword": "buku" 30 | }, 31 | "commands": { 32 | "_execute_browser_action": { 33 | "description": "Open Bukubrow" 34 | }, 35 | "add_bookmark": { 36 | "description": "Open Bukubrow and add a bookmark" 37 | }, 38 | "stage_all_tabs": { 39 | "description": "Send all tabs to Bukubrow's staging area" 40 | }, 41 | "stage_window_tabs": { 42 | "description": "Send window tabs to Bukubrow's staging area" 43 | }, 44 | "stage_active_tab": { 45 | "description": "Send the active tab to Bukubrow's staging area" 46 | } 47 | }, 48 | "minimum_chrome_version": "55", 49 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlBitSfQLABNqlk6AQGcOfBgs8Y9u+Sq1GU5+7/jsqRz4E3WqZcEX/9AHxH8FS6SXLItrBuDd80MAy6kM19uE7XgRCK4vY/6lPcIgXIzGEeOTCtO0qkRIaMhgpgHjO/XBGHH789RQ4X3PXGHvKmMdu3Jz1E9yF0cCDSuXG0eh6RKk9Ox7AL0Ldbukm7xeTD6W4Y10Ge5vnGhlMv50waK5upXcr8qXJU0s6GqbKaRH4ELfMrXmo1dltROIT0kAIkKpNJ8q9bbJ+uTZmCsDqUAH9rxiYsgWqFJ+a0EnQf8MEbvGH3hRO8PR/7gm+BI0bcIFN7OHL3nrNxcB+V2duWtdYQIDAQAB", 50 | "applications": { 51 | "gecko": { 52 | "id": "bukubrow@samhh.com", 53 | "strict_min_version": "52.0" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/backend.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { flow, constVoid, constant } from "fp-ts/lib/function" 3 | import * as T from "fp-ts/lib/Task" 4 | import * as TO from "fp-ts-contrib/lib/TaskOption" 5 | import * as O from "fp-ts/lib/Option" 6 | import * as A from "fp-ts/Array" 7 | import { initBadgeAndListen } from "~/modules/badge" 8 | import { 9 | initContextMenusAndListen, 10 | sendTabsToStagingArea, 11 | } from "~/modules/context" 12 | import { 13 | listenForIsomorphicMessages, 14 | IsomorphicMessage, 15 | sendIsomorphicMessage, 16 | } from "~/modules/comms/isomorphic" 17 | import { 18 | executeCodeInActiveTab, 19 | getBookmarksFromLocalStorageInfallible, 20 | openPopup, 21 | } from "~/modules/comms/browser" 22 | import { listenForCommands, Command } from "~/modules/command" 23 | import { runIO, runTask, runTask_ } from "~/modules/fp" 24 | import sleep from "~/modules/sleep" 25 | import { contextClickTabs, ContextMenuEntry } from "~/modules/context" 26 | import { browser, Omnibox } from "webextension-polyfill-ts" 27 | import { onOmniboxInput, onOmniboxSubmit } from "~/modules/omnibox" 28 | import { includesCI } from "~modules/string" 29 | import { mkBookmarkletCode } from "~modules/bookmarklet" 30 | 31 | const omniboxSubmitHandler = (url: string) => ( 32 | d: Omnibox.OnInputEnteredDisposition, 33 | ): IO => () => 34 | pipe( 35 | url, 36 | mkBookmarkletCode, 37 | O.fold(() => { 38 | switch (d) { 39 | case "currentTab": 40 | return void browser.tabs.update({ url }) 41 | case "newForegroundTab": 42 | case "newBackgroundTab": 43 | return void browser.tabs.create({ url }) 44 | } 45 | }, flow(executeCodeInActiveTab, runTask_)), 46 | ) 47 | 48 | runIO( 49 | onOmniboxInput(input => 50 | pipe( 51 | getBookmarksFromLocalStorageInfallible, 52 | T.map( 53 | A.filterMap(bm => 54 | includesCI(input)(bm.title) 55 | ? O.some({ content: bm.url, description: bm.title }) 56 | : O.none, 57 | ), 58 | ), 59 | ), 60 | ), 61 | ) 62 | 63 | runIO(onOmniboxSubmit(omniboxSubmitHandler)) 64 | 65 | initBadgeAndListen().then(f => { 66 | runIO( 67 | listenForIsomorphicMessages(x => { 68 | switch (x) { 69 | case IsomorphicMessage.SettingsUpdated: 70 | case IsomorphicMessage.BookmarksUpdatedInLocalStorage: 71 | runTask(f) 72 | break 73 | } 74 | }), 75 | ) 76 | }) 77 | 78 | runIO(initContextMenusAndListen(sendTabsToStagingArea)) 79 | 80 | const cmdCtx = { 81 | [Command.StageAllTabs]: ContextMenuEntry.SendAllTabs, 82 | [Command.StageWindowTabs]: ContextMenuEntry.SendActiveWindowTabs, 83 | [Command.StageActiveTab]: ContextMenuEntry.SendActiveTab, 84 | } 85 | 86 | const getTabs = contextClickTabs(O.none) 87 | 88 | runIO( 89 | listenForCommands(x => { 90 | switch (x) { 91 | case Command.AddBookmark: 92 | runIO(openPopup) 93 | runTask( 94 | pipe( 95 | // Wait 100ms so that the popup can load and start listening for 96 | // messages 97 | sleep(100), 98 | T.chain( 99 | flow( 100 | constant( 101 | sendIsomorphicMessage( 102 | IsomorphicMessage.OpenAddBookmarkCommand, 103 | ), 104 | ), 105 | T.map(constVoid), 106 | ), 107 | ), 108 | ), 109 | ) 110 | return 111 | 112 | case Command.StageAllTabs: 113 | case Command.StageWindowTabs: 114 | case Command.StageActiveTab: 115 | runTask( 116 | pipe( 117 | getTabs(cmdCtx[x]), 118 | TO.chain(flow(sendTabsToStagingArea, TO.fromTaskEither)), 119 | ), 120 | ) 121 | return 122 | } 123 | }), 124 | ) 125 | -------------------------------------------------------------------------------- /src/components/badge.ts: -------------------------------------------------------------------------------- 1 | import styled from "~/styles" 2 | import { URLMatch } from "~/modules/compare-urls" 3 | import { colors } from "~/modules/badge" 4 | 5 | export enum BadgeWeight { 6 | Primary, 7 | Secondary, 8 | None, 9 | } 10 | 11 | export const mapURLMatchToBadgeWeight = (urlMatch: URLMatch): BadgeWeight => { 12 | switch (urlMatch) { 13 | case URLMatch.Exact: 14 | return BadgeWeight.Primary 15 | case URLMatch.Domain: 16 | return BadgeWeight.Secondary 17 | default: 18 | return BadgeWeight.None 19 | } 20 | } 21 | 22 | interface Props { 23 | weight: BadgeWeight 24 | } 25 | 26 | const size = "8px" 27 | 28 | const Badge = styled.span` 29 | width: ${size}; 30 | height: ${size}; 31 | display: inline-block; 32 | vertical-align: middle; 33 | border-radius: 50%; 34 | background: ${(props): string => { 35 | switch (props.weight) { 36 | case BadgeWeight.Primary: 37 | return colors[URLMatch.Exact] 38 | case BadgeWeight.Secondary: 39 | return colors[URLMatch.Domain] 40 | case BadgeWeight.None: 41 | return "transparent" 42 | } 43 | }}; 44 | ` 45 | 46 | export default Badge 47 | -------------------------------------------------------------------------------- /src/components/bookmark-form.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef, FormEvent, FC } from "react" 2 | import { pipe } from "fp-ts/lib/pipeable" 3 | import * as O from "fp-ts/lib/Option" 4 | import styled from "~/styles" 5 | import { LocalBookmark, LocalBookmarkUnsaved } from "~/modules/bookmarks" 6 | import Button from "~/components/button" 7 | import IconButton, { idealFeatherIconSize } from "~/components/icon-button" 8 | import Tag from "~/components/tag" 9 | import TextInput from "~/components/text-input" 10 | import { Plus } from "react-feather" 11 | 12 | const Wrapper = styled.div` 13 | padding: 2rem 1rem; 14 | ` 15 | 16 | const Header = styled.header` 17 | display: flex; 18 | justify-content: space-between; 19 | align-items: center; 20 | margin: 0 0 2rem; 21 | ` 22 | 23 | const Heading = styled.h1` 24 | display: inline-block; 25 | margin: 0; 26 | font-size: 2rem; 27 | ` 28 | 29 | const TagInputWrapper = styled.div` 30 | display: grid; 31 | grid-template-columns: 1fr auto; 32 | grid-gap: 1rem; 33 | margin: 1rem 0 0; 34 | ` 35 | 36 | const AddTagButton = styled(IconButton)` 37 | align-self: end; 38 | margin: 0 0 0.5rem; 39 | ` 40 | 41 | const SubmitButton = styled(Button)` 42 | margin: 2rem 0 0; 43 | ` 44 | 45 | const TagList = styled.ul` 46 | list-style: none; 47 | margin: 0.5rem 0 0; 48 | padding: 0; 49 | ` 50 | 51 | interface Props { 52 | onSubmit(bookmark: LocalBookmark | LocalBookmarkUnsaved): void 53 | bookmark: Option> 54 | } 55 | 56 | interface BookmarkInput { 57 | id: Option 58 | title: LocalBookmark["title"] 59 | desc: LocalBookmark["desc"] 60 | url: LocalBookmark["url"] 61 | tags: LocalBookmark["tags"] 62 | } 63 | 64 | type KeyofStringValues = { 65 | [K in keyof T]: T[K] extends string ? K : never 66 | }[keyof T] 67 | 68 | const BookmarkForm: FC = props => { 69 | const firstInputRef = useRef(null) 70 | 71 | const [bookmarkInput, setBookmarkInput] = useState({ 72 | id: O.none, 73 | title: "", 74 | desc: "", 75 | url: "", 76 | tags: [], 77 | }) 78 | const setInputBookmarkPartial = ( 79 | partialBookmark: Partial, 80 | ): void => { 81 | setBookmarkInput({ ...bookmarkInput, ...partialBookmark }) 82 | } 83 | 84 | const [tagInput, setTagInput] = useState("") 85 | 86 | useEffect(() => { 87 | // Copy bookmark props into state 88 | if (O.isSome(props.bookmark)) { 89 | // Ensure not to copy unwanted properties into state 90 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 91 | const { flags, id, ...toCopy } = props.bookmark.value 92 | 93 | setInputBookmarkPartial({ ...toCopy, id: O.fromNullable(id) }) 94 | } 95 | 96 | // Focus first input automatically 97 | if (firstInputRef.current) firstInputRef.current.focus() 98 | }, []) 99 | 100 | const handleBookmarkTextInput = (key: KeyofStringValues) => ( 101 | input: string, 102 | ): void => { 103 | setInputBookmarkPartial({ [key]: input }) 104 | } 105 | 106 | const handleTagAddition = (evt: FormEvent): void => { 107 | evt.preventDefault() 108 | evt.stopPropagation() 109 | 110 | const newTag = tagInput.trim() 111 | 112 | // Disallow adding an empty tag or the same tag twice 113 | if (!newTag || bookmarkInput.tags.includes(newTag)) return 114 | 115 | setInputBookmarkPartial({ tags: [...bookmarkInput.tags, newTag] }) 116 | setTagInput("") 117 | } 118 | 119 | const handleTagRemoval = (tagToRemove: string): void => { 120 | setInputBookmarkPartial({ 121 | tags: bookmarkInput.tags.filter(tag => tag !== tagToRemove), 122 | }) 123 | } 124 | 125 | const handleSubmit = (evt: FormEvent): void => { 126 | evt.preventDefault() 127 | 128 | if (!bookmarkInput.title || !bookmarkInput.url) return 129 | 130 | const bookmark = { 131 | ...bookmarkInput, 132 | id: O.toUndefined(bookmarkInput.id), 133 | flags: 0, 134 | } 135 | 136 | props.onSubmit(bookmark) 137 | } 138 | 139 | const isEditing = pipe( 140 | props.bookmark, 141 | O.chain(bm => O.fromNullable(bm.id)), 142 | O.isSome, 143 | ) 144 | 145 | return ( 146 | <> 147 |
148 | 149 | 150 | 151 |
152 | {isEditing ? "Edit bookmark" : "Add a bookmark"} 153 |
154 | 155 | 162 | 163 | 169 | 170 | 176 | 177 | 178 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | {bookmarkInput.tags.map(tag => ( 192 | 193 | ))} 194 | 195 | 196 | 197 | {isEditing ? "Update bookmark" : "Add bookmark"} 198 | 199 |
200 | 201 | ) 202 | } 203 | 204 | export default BookmarkForm 205 | -------------------------------------------------------------------------------- /src/components/bookmark.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ 2 | 3 | import React, { 4 | memo, 5 | forwardRef, 6 | Ref, 7 | MouseEvent, 8 | useState, 9 | ReactNode, 10 | } from "react" 11 | import { URLMatch } from "~/modules/compare-urls" 12 | import { ParsedInputResult } from "~/modules/parse-search-input" 13 | import { LocalBookmark } from "~/modules/bookmarks" 14 | import styled, { css } from "~/styles" 15 | import { isBookmarkletCode } from "~/modules/bookmarklet" 16 | import Badge, { mapURLMatchToBadgeWeight } from "~/components/badge" 17 | import HighlightMarkup from "~/components/highlight-markup" 18 | import IconButton, { idealFeatherIconSize } from "~/components/icon-button" 19 | import ListItem from "~/components/list-item" 20 | import Tag from "~/components/tag" 21 | import Tooltip from "~/components/tooltip" 22 | import { Edit, Trash, Code } from "react-feather" 23 | 24 | const ControlsWrapper = styled.div` 25 | display: flex; 26 | position: relative; 27 | white-space: nowrap; 28 | align-self: flex-start; 29 | opacity: 0; 30 | margin: 0 0 0 1rem; 31 | ` 32 | 33 | const Wrapper = styled(ListItem)` 34 | display: flex; 35 | justify-content: space-between; 36 | position: relative; 37 | z-index: 0; /* For BookmarkletGraphic */ 38 | margin: 0; 39 | overflow: hidden; 40 | 41 | &:hover ${ControlsWrapper} { 42 | opacity: 1; 43 | } 44 | ` 45 | 46 | // This weirdness seems to be necessary to prevent focused being passed down 47 | // to React as an attribute which in turn triggers a warning. And beware, we've 48 | // now somehow lost type safety. See here for more: 49 | // https://github.com/styled-components/styled-components/issues/2131 50 | const BookmarkletGraphic = styled(({ focused: _, ...rest }) => ( 51 | 52 | ))<{ focused: boolean }>` 53 | position: absolute; 54 | top: 50%; 55 | right: 2rem; 56 | transform: translateY(-50%); 57 | transform-origin: right; 58 | z-index: -1; 59 | /* Overriding Sanitize for Feather */ 60 | fill: none !important; 61 | color: ${(props): string => props.theme.backgroundColorOffset}; 62 | 63 | ${ListItem}:hover & { 64 | transform: translateY(-50%) scale(4); 65 | color: ${(props): string => props.theme.backgroundColorOffsetOffset}; 66 | } 67 | 68 | ${ 69 | /* eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types */ "" 70 | } 71 | ${props => 72 | props.focused && 73 | css` 74 | transform: translateY(-50%) scale(4); 75 | color: ${props.theme.backgroundColorOffsetOffset}; 76 | `} 77 | ` 78 | 79 | const TagsList = styled.ul` 80 | display: inline; 81 | padding: 0; 82 | ` 83 | 84 | const SpacedBadge = styled(Badge)` 85 | margin: 0 0.5rem 0 0; 86 | ` 87 | 88 | const Name = styled.h1` 89 | display: inline-block; 90 | margin: 0; 91 | font-size: 1.4rem; 92 | font-weight: normal; 93 | color: ${(props): string => props.theme.textColor}; 94 | ` 95 | 96 | const Desc = styled.p` 97 | margin: 0.3rem 0; 98 | font-size: 1.1rem; 99 | ` 100 | 101 | const URL = styled.h2` 102 | margin: 0.3rem 0 0; 103 | font-size: 1rem; 104 | font-weight: normal; 105 | color: ${(props): string => props.theme.textColorOffset}; 106 | ` 107 | 108 | const ControlButton = styled(IconButton)` 109 | &:not(:last-child) { 110 | margin-right: 0.5rem; 111 | } 112 | ` 113 | 114 | const ControlButtonTooltip = styled(Tooltip)` 115 | position: absolute; 116 | top: 50%; 117 | right: calc(100% + 0.5rem); 118 | transform: translateY(-50%); 119 | ` 120 | 121 | interface Props { 122 | id: LocalBookmark["id"] 123 | title: LocalBookmark["title"] 124 | url: LocalBookmark["url"] 125 | desc: LocalBookmark["desc"] 126 | tags: LocalBookmark["tags"] 127 | activeTabURLMatch: URLMatch 128 | onEdit(id: LocalBookmark["id"]): void 129 | onDelete(id: LocalBookmark["id"]): void 130 | openBookmark?(id: LocalBookmark["id"]): void 131 | isFocused?: boolean 132 | parsedFilter?: ParsedInputResult 133 | forwardedRef?: Ref 134 | } 135 | 136 | const Bookmark = memo(props => { 137 | const [tooltipMessage, setTooltipMessage] = useState("") 138 | const [displayTooltip, setDisplayTooltip] = useState(false) 139 | 140 | const handleClick = (): void => { 141 | props.openBookmark && props.openBookmark(props.id) 142 | } 143 | 144 | const handleEdit = (evt: MouseEvent): void => { 145 | evt.stopPropagation() 146 | 147 | props.onEdit(props.id) 148 | } 149 | 150 | const handleDelete = (evt: MouseEvent): void => { 151 | evt.stopPropagation() 152 | 153 | props.onDelete(props.id) 154 | } 155 | 156 | const showTooltip = (msg: string) => (): void => { 157 | setTooltipMessage(msg) 158 | setDisplayTooltip(true) 159 | } 160 | 161 | const hideTooltip = (): void => { 162 | setDisplayTooltip(false) 163 | } 164 | 165 | const isBookmarklet = isBookmarkletCode(props.url) 166 | 167 | return ( 168 | } 172 | > 173 |
174 |
175 | 176 | {props.activeTabURLMatch !== URLMatch.None && ( 177 | 180 | )} 181 | 190 |   191 | 192 |
193 | 194 | 195 | {props.tags.map(tag => ( 196 | ( 200 | 209 | )} 210 | /> 211 | ))} 212 | 213 | 214 | {props.desc && ( 215 | 216 | >   217 | 226 | 227 | )} 228 | 229 | {!isBookmarklet && ( 230 | 231 | 240 | 241 | )} 242 |
243 | 244 | 245 |
246 | 253 | 254 | 255 | 256 | 263 | 264 | 265 |
266 | 267 | 271 |
272 | 273 | {isBookmarklet && ( 274 | 275 | )} 276 |
277 | ) 278 | }) 279 | 280 | export default forwardRef((props: Props, ref?: Ref) => ( 281 | 282 | )) 283 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, Ref, MouseEvent, ReactNode, FC } from "react" 2 | import styled from "~/styles" 3 | 4 | const Btn = styled.button` 5 | padding: 0.5rem 1rem; 6 | border: 1px solid ${(props): string => props.theme.backgroundColorOffset}; 7 | border-radius: ${(props): string => props.theme.borderRadius}; 8 | font-size: 100%; 9 | opacity: ${(props): number => (props.disabled ? 0.25 : 1)}; 10 | background: ${(props): string => props.theme.backgroundColorOffset}; 11 | color: ${(props): string => props.theme.textColor}; 12 | user-select: none; 13 | transition: background-color 0.2s; 14 | 15 | /* Has to be !important to override Firefox default */ 16 | &, 17 | &:focus { 18 | outline: none !important; 19 | } 20 | 21 | &:not(:disabled) { 22 | cursor: pointer; 23 | 24 | &:hover, 25 | &:focus { 26 | background-color: ${(props): string => 27 | props.theme.backgroundColorOffsetOffset}; 28 | } 29 | 30 | &:active { 31 | transform: translateY(2px); 32 | } 33 | } 34 | ` 35 | 36 | interface Props { 37 | children: ReactNode 38 | forwardedRef?: Ref 39 | onClick?(evt: MouseEvent): void 40 | onMouseEnter?(evt: MouseEvent): void 41 | onMouseLeave?(evt: MouseEvent): void 42 | type?: "button" | "submit" 43 | form?: string 44 | disabled?: boolean 45 | tabIndex?: number 46 | className?: string 47 | } 48 | 49 | const Button: FC = props => ( 50 | 61 | {props.children} 62 | 63 | ) 64 | 65 | export default forwardRef((props: Props, ref?: Ref) => ( 66 | 60 | 63 | 66 | 67 | 68 | ) 69 | 70 | const Instructions: FC<{ OS: OperatingSystem }> = props => { 71 | switch (props.OS) { 72 | case OperatingSystem.Linux: 73 | case OperatingSystem.MacOS: 74 | return ( 75 | <> 76 |

77 | Installing the host and registering it with your browser is required 78 | to allow Bukubrow to talk to Buku. 79 |

80 | 81 |

82 | Please find a precompiled host and instructions at{" "} 83 | 88 | samhh/bukubrow-host 89 | 90 | . 91 |

92 | 93 | ) 94 | 95 | case OperatingSystem.Windows: 96 | return ( 97 | <> 98 |

99 | Unfortunately, Windows is not yet formally supported by Bukubrow. 100 |

101 | 102 |

103 | If you'd like to offer your help in achieving support, or 104 | otherwise simply +1 it as a feature request, please do so{" "} 105 | 110 | here 111 | 112 | . 113 |

114 | 115 | ) 116 | } 117 | } 118 | 119 | const Onboarding: FC = () => { 120 | const [OS, setOS] = useState>(O.none) 121 | 122 | const Content = O.fold( 123 | () => , 124 | x => , 125 | )(OS) 126 | 127 | return ( 128 | 129 | 130 | 131 | {Content} 132 | 133 | ) 134 | } 135 | 136 | export default Onboarding 137 | -------------------------------------------------------------------------------- /src/components/tag.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import styled, { css } from "~/styles" 3 | import { FlattenSimpleInterpolation } from "styled-components" 4 | 5 | const TagItem = styled.li<{ removable: boolean }>` 6 | display: inline-block; 7 | margin: 0; 8 | font-size: 1.3rem; 9 | font-weight: normal; 10 | color: ${(props): string => props.theme.textColorOffset}; 11 | 12 | ${(props): FlattenSimpleInterpolation | false => 13 | props.removable && 14 | css` 15 | cursor: pointer; 16 | 17 | &:hover { 18 | text-decoration: line-through; 19 | } 20 | `} 21 | 22 | &:not(:last-child) { 23 | margin-right: 0.5rem; 24 | } 25 | ` 26 | 27 | interface Props { 28 | id: string 29 | label: string | (() => React.ReactNode) 30 | onRemove?(tag: string): void 31 | } 32 | 33 | const Tag: FC = props => { 34 | const handleRemove = (): void => { 35 | if (props.onRemove) props.onRemove(props.id) 36 | } 37 | 38 | return ( 39 | 40 | #{typeof props.label === "string" ? props.label : props.label()} 41 | 42 | ) 43 | } 44 | 45 | export default Tag 46 | -------------------------------------------------------------------------------- /src/components/text-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, Ref, FormEvent, FC } from "react" 2 | import styled from "~/styles" 3 | 4 | const Wrapper = styled.div` 5 | width: 100%; 6 | 7 | & + & { 8 | margin-top: 1rem; 9 | } 10 | ` 11 | 12 | const Header = styled.header` 13 | display: flex; 14 | justify-content: space-between; 15 | ` 16 | 17 | const Label = styled.label` 18 | margin: 0 0 0.5rem; 19 | ` 20 | 21 | const Counter = styled.span<{ alerted: boolean }>` 22 | font-size: 1.3rem; 23 | font-weight: 900; 24 | color: ${(props): string => (props.alerted ? "#ec1d26" : "#989898")}; 25 | ` 26 | 27 | const Input = styled.input` 28 | width: 100%; 29 | padding: 0.5rem 1rem; 30 | border: 1px solid ${(props): string => props.theme.backgroundColorOffset}; 31 | border-radius: ${(props): string => props.theme.borderRadius}; 32 | border-radius: 3px; 33 | background: none; 34 | color: ${(props): string => props.theme.textColor}; 35 | 36 | &::placeholder { 37 | color: "#9d9da3"; 38 | } 39 | 40 | &:focus { 41 | outline: none; 42 | border-color: ${(props): string => props.theme.backgroundColorOffsetOffset}; 43 | } 44 | 45 | &:disabled { 46 | opacity: 0.25; 47 | font-style: italic; 48 | } 49 | ` 50 | 51 | interface Props { 52 | value: string 53 | onInput(value: string): void 54 | forwardedRef?: Ref 55 | form?: string 56 | label?: string 57 | placeholder?: string 58 | max?: number 59 | required?: boolean 60 | disabled?: boolean 61 | autoComplete?: boolean 62 | className?: string 63 | } 64 | 65 | const exceedsMaxLength = (newValue: string, max?: number): boolean => 66 | typeof max === "number" && newValue.length > max 67 | const isBackspacing = (oldValue: string, newValue: string): boolean => 68 | oldValue.length > newValue.length 69 | const isInvalidInput = ( 70 | oldValue: string, 71 | newValue: string, 72 | max?: number, 73 | ): boolean => 74 | exceedsMaxLength(newValue, max) && !isBackspacing(oldValue, newValue) 75 | 76 | const TextInput: FC = props => { 77 | const handleInput = ( 78 | evt: FormEvent, 79 | ): void => { 80 | const { 81 | currentTarget: { value }, 82 | } = evt 83 | 84 | if (isInvalidInput(props.value, value, props.max)) return 85 | 86 | props.onInput(value) 87 | } 88 | 89 | const renderHeader = !!(props.label || props.max) 90 | 91 | return ( 92 | 93 | {renderHeader && ( 94 |
95 | 98 | 99 | {props.max && ( 100 | props.max}> 101 | {props.max - props.value.length} 102 | 103 | )} 104 |
105 | )} 106 | 107 | 118 |
119 | ) 120 | } 121 | 122 | export default forwardRef((props: Props, ref?: Ref) => ( 123 | 124 | )) 125 | -------------------------------------------------------------------------------- /src/components/title-menu.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import { ArrowLeft } from "react-feather" 3 | import styled from "~/styles" 4 | 5 | const height = 40 6 | const padding = 5 7 | const border = 1 8 | 9 | const Wrapper = styled.nav` 10 | height: ${height}px; 11 | position: relative; 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | margin: 0 0 ${border}px; 16 | padding: ${padding}px; 17 | 18 | &::after { 19 | content: ""; 20 | width: 100%; 21 | height: ${border}px; 22 | position: absolute; 23 | top: 100%; 24 | left: 0; 25 | background: linear-gradient( 26 | 90deg, 27 | transparent 5%, 28 | ${(props): string => props.theme.textColor} 50%, 29 | transparent 95% 30 | ); 31 | } 32 | ` 33 | 34 | const IconWrapper = styled.span` 35 | position: absolute; 36 | top: 50%; 37 | left: ${padding}px; 38 | transform: translateY(-50%); 39 | cursor: pointer; 40 | 41 | svg { 42 | /* Overriding Sanitize for Feather */ 43 | fill: none !important; 44 | } 45 | ` 46 | 47 | const Header = styled.header` 48 | font-weight: bold; 49 | ` 50 | 51 | interface Props { 52 | title: string 53 | onBack(): void 54 | } 55 | 56 | const TitleMenu: FC = props => ( 57 | 58 | 59 | 60 | 61 | 62 |
{props.title}
63 |
64 | ) 65 | 66 | export default TitleMenu 67 | -------------------------------------------------------------------------------- /src/components/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import styled from "~/styles" 3 | 4 | const StyledTooltip = styled.span<{ visible: boolean }>` 5 | padding: 0.5rem 1rem; 6 | border: 1px solid ${(props): string => props.theme.backgroundColorOffset}; 7 | white-space: nowrap; 8 | border-radius: ${(props): string => props.theme.borderRadius}; 9 | visibility: ${(props): string => (props.visible ? "visible" : "hidden")}; 10 | opacity: ${(props): number => (props.visible ? 1 : 0)}; 11 | font-size: 1.4rem; 12 | background: ${(props): string => props.theme.backgroundColor}; 13 | transition: opacity 0.3s; 14 | ` 15 | 16 | interface Props { 17 | message: string 18 | visible: boolean 19 | className?: string 20 | } 21 | 22 | const Tooltip: FC = props => ( 23 | 24 | {props.message} 25 | 26 | ) 27 | 28 | export default Tooltip 29 | -------------------------------------------------------------------------------- /src/containers/bookmark-add-form.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import * as O from "fp-ts/lib/Option" 3 | import { useSelector, useDispatch } from "~/store" 4 | import { addBookmark } from "~/store/bookmarks/epics" 5 | import BookmarkForm from "~/components/bookmark-form" 6 | 7 | const BookmarkAddForm: FC = () => { 8 | const { pageTitle, pageUrl } = useSelector(state => state.browser) 9 | const dispatch = useDispatch() 10 | 11 | return ( 12 | void dispatch(addBookmark(bm))} 18 | /> 19 | ) 20 | } 21 | 22 | export default BookmarkAddForm 23 | -------------------------------------------------------------------------------- /src/containers/bookmark-delete-form.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import { pipe } from "fp-ts/lib/pipeable" 3 | import * as O from "fp-ts/lib/Option" 4 | import { useSelector, useDispatch } from "~/store" 5 | import { setDeleteBookmarkModalDisplay } from "~/store/bookmarks/actions" 6 | import { deleteBookmark } from "~/store/bookmarks/epics" 7 | import { getBookmarkToDelete } from "~/store/selectors" 8 | import styled from "~/styles" 9 | import Button from "~/components/button" 10 | import Modal from "~/components/modal" 11 | 12 | const Heading = styled.h1` 13 | margin: 0 0 1rem; 14 | font-size: 2rem; 15 | ` 16 | 17 | const ConfirmationButton = styled(Button)` 18 | margin: 0 0 0 0.5rem; 19 | ` 20 | 21 | const BookmarkDeleteForm: FC = () => { 22 | const bmToDel = useSelector(getBookmarkToDelete) 23 | const shouldDisplay = useSelector( 24 | state => state.bookmarks.displayDeleteBookmarkModal, 25 | ) 26 | const dispatch = useDispatch() 27 | 28 | const display = shouldDisplay && O.isSome(bmToDel) 29 | if (!display) return null 30 | 31 | const bookmarkTitle = pipe( 32 | bmToDel, 33 | O.fold( 34 | () => "", 35 | bm => bm.title, 36 | ), 37 | ) 38 | return ( 39 | 40 |
41 | 42 | Delete bookmark {bookmarkTitle}? 43 | 44 |
45 | 46 | 53 | void dispatch(deleteBookmark())}> 54 | Delete 55 | 56 |
57 | ) 58 | } 59 | 60 | export default BookmarkDeleteForm 61 | -------------------------------------------------------------------------------- /src/containers/bookmark-edit-form.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import { useSelector, useDispatch } from "~/store" 3 | import { getBookmarkToEdit } from "~/store/selectors" 4 | import { updateBookmark } from "~/store/bookmarks/epics" 5 | import { LocalBookmark } from "~/modules/bookmarks" 6 | import BookmarkForm from "~/components/bookmark-form" 7 | 8 | const BookmarkEditForm: FC = () => { 9 | const bookmarkToEdit = useSelector(getBookmarkToEdit) 10 | const dispatch = useDispatch() 11 | 12 | return ( 13 | void dispatch(updateBookmark(bm))} 17 | /> 18 | ) 19 | } 20 | 21 | export default BookmarkEditForm 22 | -------------------------------------------------------------------------------- /src/containers/bookmarks-list.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, FC } from "react" 2 | import { pipe } from "fp-ts/lib/pipeable" 3 | import * as O from "fp-ts/lib/Option" 4 | import { runIO } from "~/modules/fp" 5 | import { useSelector, useDispatch } from "~/store" 6 | import { 7 | getFocusedBookmark, 8 | getParsedFilter, 9 | getWeightedLimitedFilteredBookmarks, 10 | } from "~/store/selectors" 11 | import { 12 | initiateBookmarkEdit, 13 | initiateBookmarkDeletion, 14 | attemptFocusedBookmarkIndexIncrement, 15 | attemptFocusedBookmarkIndexDecrement, 16 | openBookmarkAndExit, 17 | } from "~/store/bookmarks/epics" 18 | // import { Key } from 'ts-key-enum'; 19 | import { scrollToEl } from "~/modules/scroll-window" 20 | import useListenToKeydown from "~/hooks/listen-to-keydown" 21 | import styled from "~/styles" 22 | import Bookmark from "~/components/bookmark" 23 | 24 | const WrapperList = styled.ul` 25 | margin: 0; 26 | padding: 0; 27 | border: 1px solid ${(props): string => props.theme.backgroundColorOffset}; 28 | list-style: none; 29 | ` 30 | 31 | const BookmarksList: FC = () => { 32 | const bookmarks = useSelector(getWeightedLimitedFilteredBookmarks) 33 | const focusedBookmark = useSelector(getFocusedBookmark) 34 | const focusedBookmarkId = pipe( 35 | focusedBookmark, 36 | O.map(bm => bm.id), 37 | ) 38 | const parsedFilter = useSelector(getParsedFilter) 39 | const dispatch = useDispatch() 40 | 41 | // Prevent stale data in keydown hook callback 42 | const keydownDataRef = useRef({ focusedBookmark }) 43 | keydownDataRef.current = { focusedBookmark } 44 | 45 | const activeBookmarkEl = useRef(null) 46 | 47 | useListenToKeydown(evt => { 48 | if (evt.key === "Enter") { 49 | const { focusedBookmark: liveFocusedBookmark } = keydownDataRef.current 50 | 51 | if (O.isSome(liveFocusedBookmark)) { 52 | dispatch(openBookmarkAndExit(liveFocusedBookmark.value.id)) 53 | } 54 | } 55 | 56 | // preventDefault to prevent keyboard scrolling 57 | if (evt.key === "ArrowUp") { 58 | evt.preventDefault() 59 | dispatch(attemptFocusedBookmarkIndexDecrement()) 60 | 61 | if (activeBookmarkEl && activeBookmarkEl.current) 62 | runIO(scrollToEl(activeBookmarkEl.current)) 63 | } 64 | 65 | if (evt.key === "ArrowDown") { 66 | evt.preventDefault() 67 | dispatch(attemptFocusedBookmarkIndexIncrement()) 68 | 69 | if (activeBookmarkEl && activeBookmarkEl.current) 70 | runIO(scrollToEl(activeBookmarkEl.current)) 71 | } 72 | }) 73 | 74 | return ( 75 | 76 | {bookmarks.map(bookmark => { 77 | const isFocused = bookmark.id === O.toNullable(focusedBookmarkId) 78 | 79 | return ( 80 | dispatch(openBookmarkAndExit(bmId))} 91 | onEdit={(bmId): void => dispatch(initiateBookmarkEdit(bmId))} 92 | onDelete={(bmId): void => dispatch(initiateBookmarkDeletion(bmId))} 93 | ref={isFocused ? activeBookmarkEl : undefined} 94 | /> 95 | ) 96 | })} 97 | 98 | ) 99 | } 100 | 101 | export default BookmarksList 102 | -------------------------------------------------------------------------------- /src/containers/error-messages.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import { useSelector } from "~/store" 3 | import ErrorPopup from "~/components/error-popup" 4 | 5 | const ErrorMessages: FC = () => { 6 | const errors = useSelector(state => state.notices.errors) 7 | const errMsgs = Object.values(errors).filter( 8 | (err): err is string => typeof err === "string", 9 | ) 10 | 11 | return ( 12 | <> 13 | {errMsgs.map(err => ( 14 | 15 | ))} 16 | 17 | ) 18 | } 19 | 20 | export default ErrorMessages 21 | -------------------------------------------------------------------------------- /src/containers/open-all-bookmarks-confirmation.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import { useDispatch, useSelector } from "~/store" 3 | import * as R from "fp-ts/lib/Record" 4 | import { matchesTerminology } from "~/modules/terminology" 5 | import { setDisplayOpenAllBookmarksConfirmation } from "~/store/user/actions" 6 | import { openAllFilteredBookmarksAndExit } from "~/store/bookmarks/epics" 7 | import { getUnlimitedFilteredBookmarks } from "~/store/selectors" 8 | import Button from "~/components/button" 9 | 10 | import Modal from "~/components/modal" 11 | import styled from "~/styles" 12 | 13 | const Heading = styled.h1` 14 | margin: 0 0 1rem; 15 | font-size: 2rem; 16 | ` 17 | 18 | const ConfirmationButton = styled(Button)` 19 | margin: 0 0 0 0.5rem; 20 | ` 21 | 22 | const OpenAllBookmarksConfirmation: FC = () => { 23 | const display = useSelector( 24 | state => state.user.displayOpenAllBookmarksConfirmation, 25 | ) 26 | const filteredBookmarks = useSelector(getUnlimitedFilteredBookmarks) 27 | const dispatch = useDispatch() 28 | 29 | const numFilteredBookmarks = R.size(filteredBookmarks) 30 | 31 | return ( 32 | <> 33 | {display && ( 34 | 35 |
36 | {matchesTerminology(numFilteredBookmarks)}? 37 |
38 | 39 | 46 | dispatch(openAllFilteredBookmarksAndExit())} 48 | > 49 | Open 50 | 51 |
52 | )} 53 | 54 | ) 55 | } 56 | 57 | export default OpenAllBookmarksConfirmation 58 | -------------------------------------------------------------------------------- /src/containers/search-controls.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState, FC } from "react" 2 | import { useDispatch, useSelector } from "~/store" 3 | import * as A from "fp-ts/lib/Array" 4 | import * as R from "fp-ts/lib/Record" 5 | import { setSearchFilterWithResets } from "~/store/epics" 6 | import { getUnlimitedFilteredBookmarks } from "~/store/selectors" 7 | import { 8 | setDisplayOpenAllBookmarksConfirmation, 9 | setPage, 10 | } from "~/store/user/actions" 11 | import { Page, commsL } from "~/store/user/types" 12 | import { scrollToTop } from "~/modules/scroll-window" 13 | import { matchesTerminology } from "~/modules/terminology" 14 | import styled from "~/styles" 15 | import IconButton, { 16 | iconButtonSize, 17 | idealFeatherIconSize, 18 | } from "~/components/icon-button" 19 | import TextInput from "~/components/text-input" 20 | import Tooltip from "~/components/tooltip" 21 | import { ArrowUpRight, Layers, Plus } from "react-feather" 22 | import { HostVersionCheckResult } from "~/modules/comms/native" 23 | 24 | export const headerHeight = "50px" 25 | export const headerItemsMargin = "10px" 26 | 27 | const Wrapper = styled.nav` 28 | width: 100%; 29 | height: ${headerHeight}; 30 | position: fixed; 31 | top: 0; 32 | z-index: 1; 33 | display: grid; 34 | grid-template-columns: 1fr auto; 35 | grid-gap: 0.5rem; 36 | align-items: center; 37 | padding: 1rem; 38 | background: ${(props): string => props.theme.backgroundColor}; 39 | ` 40 | 41 | const SearchTextInput = styled(TextInput)` 42 | height: ${iconButtonSize}px; 43 | padding: 0 ${headerItemsMargin}; 44 | color: ${(props): string => props.theme.textColor}; 45 | ` 46 | 47 | const ControlsWrapper = styled.div` 48 | position: relative; 49 | ` 50 | 51 | const ControlButtonTooltip = styled(Tooltip)` 52 | position: absolute; 53 | top: calc(50% - 1px); 54 | right: calc(100% + 0.5rem); 55 | transform: translateY(-50%); 56 | ` 57 | 58 | const ControlButton = styled(IconButton)` 59 | vertical-align: top; 60 | 61 | &:not(:last-child) { 62 | margin-right: 0.5rem; 63 | } 64 | ` 65 | 66 | enum HoverState { 67 | None, 68 | Stage, 69 | OpenAll, 70 | Add, 71 | } 72 | 73 | const SearchControls: FC = () => { 74 | const allBookmarks = useSelector(state => state.bookmarks.bookmarks) 75 | const comms = useSelector(commsL.get) 76 | const stagedGroups = useSelector( 77 | state => state.bookmarks.stagedBookmarksGroups, 78 | ) 79 | const filteredBookmarks = useSelector(getUnlimitedFilteredBookmarks) 80 | const textFilter = useSelector(state => state.input.searchFilter) 81 | const dispatch = useDispatch() 82 | 83 | const hasBinaryComms = comms === HostVersionCheckResult.Okay 84 | const numFilteredBookmarks = R.size(filteredBookmarks) 85 | const shouldEnableSearch = !R.isEmpty(allBookmarks) 86 | const shouldEnableOpenStaged = hasBinaryComms && !A.isEmpty(stagedGroups) 87 | const shouldEnableOpenAll = !R.isEmpty(filteredBookmarks) 88 | const shouldEnableAddBookmark = hasBinaryComms 89 | 90 | const inputRef = useRef(null) 91 | const [hoverState, setHoverState] = useState(HoverState.None) 92 | 93 | const focusInput = (): void => { 94 | if (shouldEnableSearch && inputRef.current) inputRef.current.focus() 95 | } 96 | useEffect(focusInput, [shouldEnableSearch]) 97 | 98 | const showTooltip = (state: HoverState) => (): void => { 99 | setHoverState(state) 100 | } 101 | 102 | const hideTooltip = (): void => { 103 | setHoverState(HoverState.None) 104 | } 105 | 106 | const tooltipMessage = (state: HoverState): string => { 107 | switch (state) { 108 | case HoverState.Stage: 109 | return "Open staging area" 110 | case HoverState.OpenAll: 111 | return matchesTerminology(numFilteredBookmarks) 112 | case HoverState.Add: 113 | return "Add a bookmark" 114 | case HoverState.None: 115 | return "" 116 | } 117 | } 118 | 119 | return ( 120 | 121 | { 124 | dispatch(setSearchFilterWithResets(text)) 125 | scrollToTop() 126 | }} 127 | placeholder="Search..." 128 | disabled={!shouldEnableSearch} 129 | ref={inputRef} 130 | /> 131 | 132 | 133 |
134 | void dispatch(setPage(Page.StagedGroupsList))} 137 | onMouseEnter={ 138 | shouldEnableOpenStaged ? showTooltip(HoverState.Stage) : undefined 139 | } 140 | onMouseLeave={hideTooltip} 141 | > 142 | 143 | 144 | 145 | 148 | void dispatch(setDisplayOpenAllBookmarksConfirmation(true)) 149 | } 150 | onMouseEnter={ 151 | shouldEnableOpenAll ? showTooltip(HoverState.OpenAll) : undefined 152 | } 153 | onMouseLeave={hideTooltip} 154 | > 155 | 156 | 157 | 158 | void dispatch(setPage(Page.AddBookmark))} 161 | onMouseEnter={ 162 | shouldEnableAddBookmark ? showTooltip(HoverState.Add) : undefined 163 | } 164 | onMouseLeave={hideTooltip} 165 | > 166 | 167 | 168 |
169 | 170 | 174 |
175 |
176 | ) 177 | } 178 | 179 | export default SearchControls 180 | -------------------------------------------------------------------------------- /src/containers/search.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import { useDispatch, useSelector } from "~/store" 3 | import { setLimitNumRendered } from "~/store/bookmarks/actions" 4 | import { getNumFilteredUnrenderedBookmarks } from "~/store/selectors" 5 | import styled from "~/styles" 6 | import BookmarksList from "~/containers/bookmarks-list" 7 | import LoadMoreBookmarks from "~/components/load-more-bookmarks" 8 | import SearchControls, { headerHeight } from "~/containers/search-controls" 9 | 10 | const Wrapper = styled.div` 11 | padding: ${headerHeight} 0 0; 12 | ` 13 | 14 | const Search: FC = () => { 15 | const numRemainingBookmarks = useSelector(getNumFilteredUnrenderedBookmarks) 16 | const dispatch = useDispatch() 17 | 18 | return ( 19 | 20 | 21 | 22 |
23 | 24 | 25 | {!!numRemainingBookmarks && ( 26 | 29 | void dispatch(setLimitNumRendered(false)) 30 | } 31 | /> 32 | )} 33 |
34 |
35 | ) 36 | } 37 | 38 | export default Search 39 | -------------------------------------------------------------------------------- /src/containers/staged-group-bookmark-edit-form.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import * as O from "fp-ts/lib/Option" 3 | import { useDispatch, useSelector } from "~/store" 4 | import { getStagedGroupBookmarkToEdit } from "~/store/selectors" 5 | import { updateStagedBookmarksGroupBookmark } from "~/store/bookmarks/actions" 6 | import { setPage } from "~/store/user/actions" 7 | import { Page } from "~/store/user/types" 8 | import { LocalBookmark } from "~/modules/bookmarks" 9 | import BookmarkForm from "~/components/bookmark-form" 10 | 11 | const StagedGroupBookmarkEditForm: FC = () => { 12 | const bookmark = useSelector(getStagedGroupBookmarkToEdit) 13 | const groupEditId = useSelector( 14 | state => state.bookmarks.stagedBookmarksGroupEditId, 15 | ) 16 | const dispatch = useDispatch() 17 | 18 | const handleSubmit = (bm: LocalBookmark): void => { 19 | if (O.isSome(groupEditId)) { 20 | dispatch(updateStagedBookmarksGroupBookmark(groupEditId.value, bm)) 21 | dispatch(setPage(Page.StagedGroup)) 22 | } 23 | } 24 | 25 | if (O.isNone(bookmark)) return null 26 | 27 | return 28 | } 29 | 30 | export default StagedGroupBookmarkEditForm 31 | -------------------------------------------------------------------------------- /src/containers/staged-group-bookmarks-list.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import { constVoid } from "fp-ts/lib/function" 3 | import * as O from "fp-ts/lib/Option" 4 | import { pipe } from "fp-ts/lib/pipeable" 5 | import { useDispatch, useSelector } from "~/store" 6 | import { 7 | setStagedBookmarksGroupBookmarkEditId, 8 | deleteStagedBookmarksGroup, 9 | } from "~/store/bookmarks/actions" 10 | import { setPage } from "~/store/user/actions" 11 | import { getStagedGroupToEditWeightedBookmarks } from "~/store/selectors" 12 | import { 13 | deleteStagedBookmarksGroupBookmarkOrEntireGroup, 14 | openBookmarkAndExit, 15 | addAllBookmarksFromStagedGroup, 16 | } from "~/store/bookmarks/epics" 17 | import { Page } from "~/store/user/types" 18 | import { LocalBookmarkWeighted } from "~/modules/bookmarks" 19 | import styled from "~/styles" 20 | import Bookmark from "~/components/bookmark" 21 | import Button from "~/components/button" 22 | 23 | const WrapperList = styled.ul` 24 | margin: 0; 25 | padding: 0; 26 | border: 1px solid ${(props): string => props.theme.backgroundColorOffset}; 27 | list-style: none; 28 | ` 29 | 30 | const ControlsWrapper = styled.div` 31 | padding: 1rem; 32 | ` 33 | 34 | const ControlsButton = styled(Button)` 35 | &:not(:last-child) { 36 | margin-right: 1rem; 37 | } 38 | ` 39 | 40 | const StagedGroupBookmarksList: FC = () => { 41 | const stagedGroupId = useSelector( 42 | state => state.bookmarks.stagedBookmarksGroupEditId, 43 | ) 44 | const bookmarksMaybe = useSelector(getStagedGroupToEditWeightedBookmarks) 45 | const bookmarks = O.getOrElse(() => [] as Array)( 46 | bookmarksMaybe, 47 | ) 48 | const dispatch = useDispatch() 49 | 50 | const handleOpenBookmark = (bmId: number): void => { 51 | dispatch(openBookmarkAndExit(bmId, stagedGroupId)) 52 | } 53 | 54 | const handleEditBookmark = (bmId: number): void => { 55 | dispatch(setStagedBookmarksGroupBookmarkEditId(O.some(bmId))) 56 | dispatch(setPage(Page.EditStagedBookmark)) 57 | } 58 | 59 | const [handleDeleteBookmark, handleDeleteGroup, handlePublish] = pipe( 60 | stagedGroupId, 61 | O.fold( 62 | () => [constVoid, constVoid, constVoid], 63 | grpId => [ 64 | (bmId: number): void => { 65 | dispatch(deleteStagedBookmarksGroupBookmarkOrEntireGroup(grpId, bmId)) 66 | }, 67 | (): void => { 68 | dispatch(deleteStagedBookmarksGroup(grpId)) 69 | dispatch(setPage(Page.StagedGroupsList)) 70 | }, 71 | (): void => { 72 | dispatch(addAllBookmarksFromStagedGroup(grpId)) 73 | dispatch(setPage(Page.StagedGroupsList)) 74 | }, 75 | ], 76 | ), 77 | ) 78 | 79 | return ( 80 | <> 81 | 82 | {bookmarks.map(bookmark => ( 83 | 95 | ))} 96 | 97 | 98 | 99 | 100 | Delete Group 101 | 102 | 103 | Commit Bookmarks to Buku 104 | 105 | 106 | 107 | ) 108 | } 109 | 110 | export default StagedGroupBookmarksList 111 | -------------------------------------------------------------------------------- /src/containers/staged-groups-list.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import * as O from "fp-ts/lib/Option" 3 | import { formatStagedBookmarksGroupTitle } from "~/modules/bookmarks" 4 | import { useDispatch, useSelector } from "~/store" 5 | import { setPage } from "~/store/user/actions" 6 | import { getSortedStagedGroups } from "~/store/selectors" 7 | import { setStagedBookmarksGroupEditId } from "~/store/bookmarks/actions" 8 | import styled from "~/styles" 9 | import { Page } from "~/store/user/types" 10 | import ListItem from "~/components/list-item" 11 | 12 | const Wrapper = styled.ol` 13 | list-style: none; 14 | padding: 0; 15 | ` 16 | 17 | const GroupTitle = styled.header` 18 | margin: 0.5rem 0; 19 | ` 20 | 21 | const Message = styled.p` 22 | padding: 0 1rem; 23 | text-align: center; 24 | ` 25 | 26 | const StagedGroupsList: FC = () => { 27 | const groups = useSelector(getSortedStagedGroups) 28 | const dispatch = useDispatch() 29 | 30 | const handleGroupClick = (id: number): void => { 31 | dispatch(setStagedBookmarksGroupEditId(O.some(id))) 32 | dispatch(setPage(Page.StagedGroup)) 33 | } 34 | 35 | return ( 36 | 37 | {groups.length ? ( 38 | groups.map(grp => ( 39 | handleGroupClick(grp.id)}> 40 | {formatStagedBookmarksGroupTitle(grp)} 41 | 42 | )) 43 | ) : ( 44 | There are no groups in the staging area. 45 | )} 46 | 47 | ) 48 | } 49 | 50 | export default StagedGroupsList 51 | -------------------------------------------------------------------------------- /src/content.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Bukubrow - Content 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/content.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import * as O from "fp-ts/lib/Option" 3 | import mount from "~/modules/connected-mount" 4 | import { useDispatch, useSelector } from "~/store" 5 | import styled from "~/styles" 6 | import { Page, commsL } from "~/store/user/types" 7 | import { setPage } from "~/store/user/actions" 8 | import { getStagedGroupToEdit } from "~/store/selectors" 9 | import { formatStagedBookmarksGroupTitle } from "~/modules/bookmarks" 10 | import { runIO } from "~/modules/fp" 11 | import { HostVersionCheckResult } from "~/modules/comms/native" 12 | 13 | import BookmarkAddForm from "~/containers/bookmark-add-form" 14 | import BookmarkDeleteForm from "~/containers/bookmark-delete-form" 15 | import BookmarkEditForm from "~/containers/bookmark-edit-form" 16 | import ErrorMessages from "~/containers/error-messages" 17 | import Onboarding from "~/components/onboarding" 18 | import OpenAllBookmarksConfirmation from "~/containers/open-all-bookmarks-confirmation" 19 | import Search from "~/containers/search" 20 | import StagedGroupBookmarkEditForm from "~/containers/staged-group-bookmark-edit-form" 21 | import StagedGroupBookmarksList from "~/containers/staged-group-bookmarks-list" 22 | import StagedGroupsList from "~/containers/staged-groups-list" 23 | import TitleMenu from "~/components/title-menu" 24 | 25 | interface PageInfoSansTitleMenu { 26 | component: FC 27 | } 28 | 29 | type PageInfo = { 30 | // Search page does not use title menu, all other pages do 31 | [K in Page]: K extends Page.Search 32 | ? PageInfoSansTitleMenu 33 | : PageInfoSansTitleMenu & { 34 | nav: { 35 | title: string 36 | exitTarget: Page 37 | } 38 | } 39 | } 40 | 41 | interface PageMapArg { 42 | activePage: Page 43 | stagedGroupTitle: Option 44 | } 45 | 46 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 47 | const pageMap = ({ activePage, stagedGroupTitle }: PageMapArg) => { 48 | const map: PageInfo = { 49 | [Page.Search]: { 50 | component: Search, 51 | }, 52 | [Page.AddBookmark]: { 53 | nav: { 54 | title: "Add Bookmark", 55 | exitTarget: Page.Search, 56 | }, 57 | component: BookmarkAddForm, 58 | }, 59 | [Page.EditBookmark]: { 60 | nav: { 61 | title: "Edit Bookmark", 62 | exitTarget: Page.Search, 63 | }, 64 | component: BookmarkEditForm, 65 | }, 66 | [Page.StagedGroupsList]: { 67 | nav: { 68 | title: "Staging Area", 69 | exitTarget: Page.Search, 70 | }, 71 | component: StagedGroupsList, 72 | }, 73 | [Page.StagedGroup]: { 74 | nav: { 75 | title: O.getOrElse(() => "")(stagedGroupTitle), 76 | exitTarget: Page.StagedGroupsList, 77 | }, 78 | component: StagedGroupBookmarksList, 79 | }, 80 | [Page.EditStagedBookmark]: { 81 | nav: { 82 | title: "Edit Bookmark", 83 | exitTarget: Page.StagedGroup, 84 | }, 85 | component: StagedGroupBookmarkEditForm, 86 | }, 87 | } 88 | 89 | return map[activePage] 90 | } 91 | 92 | /** 93 | * Effectively sets minimum height for the page. This ensures that error 94 | * messages are always visible, and that the popup looks reasonable. 95 | */ 96 | const MinHeightWrapper = styled.main` 97 | min-height: 400px; 98 | ` 99 | 100 | const ContentApp: FC = () => { 101 | const comms = useSelector(commsL.get) 102 | const activePage = useSelector(state => state.user.page) 103 | const stagedGroupTitle = O.map(formatStagedBookmarksGroupTitle)( 104 | useSelector(getStagedGroupToEdit), 105 | ) 106 | const dispatch = useDispatch() 107 | 108 | const displayOnboarding = comms === HostVersionCheckResult.NoComms 109 | 110 | const page = pageMap({ activePage, stagedGroupTitle }) 111 | 112 | return ( 113 | <> 114 | 115 | 116 | 117 | 118 | {!displayOnboarding && } 119 | 120 | 121 | {displayOnboarding ? ( 122 | 123 | ) : ( 124 | <> 125 | {"nav" in page && ( 126 | void dispatch(setPage(page.nav.exitTarget))} 129 | /> 130 | )} 131 | 132 | 133 | 134 | )} 135 | 136 | 137 | ) 138 | } 139 | 140 | runIO(mount()) 141 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | type Option = import("fp-ts/lib/Option").Option 2 | type OptionTuple = Option<[A, B]> 3 | type Either = import("fp-ts/lib/Either").Either 4 | type EitherOption = Either> 5 | type These = import("fp-ts/lib/These").These 6 | type Task = import("fp-ts/lib/Task").Task 7 | type TaskEither = import("fp-ts/lib/TaskEither").TaskEither 8 | type TaskOption = import("fp-ts-contrib/lib/TaskOption").TaskOption 9 | type TaskEitherOption = TaskEither> 10 | type IO = import("fp-ts/lib/IO").IO 11 | type NonEmptyArray = import("fp-ts/lib/NonEmptyArray").NonEmptyArray 12 | 13 | type Lazy = import("fp-ts/lib/function").Lazy 14 | type Predicate = import("fp-ts/lib/function").Predicate 15 | type Refinement = import("fp-ts/lib/function").Refinement 16 | type Endomorphism = import("fp-ts/lib/function").Endomorphism 17 | 18 | type DeepPartial = { 19 | [P in keyof T]?: T[P] extends Array 20 | ? Array> 21 | : T[P] extends ReadonlyArray 22 | ? ReadonlyArray> 23 | : DeepPartial 24 | } 25 | 26 | type UnwrapOptions = { 27 | [K in keyof T]: T[K] extends Option ? U : T[K] 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/listen-to-keydown.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | 3 | const useListenToKeydown = (cb: (evt: KeyboardEvent) => void): void => { 4 | useEffect(() => { 5 | document.addEventListener("keydown", cb) 6 | 7 | return (): void => { 8 | document.removeEventListener("keydown", cb) 9 | } 10 | }, []) 11 | } 12 | 13 | export default useListenToKeydown 14 | -------------------------------------------------------------------------------- /src/modules/array.test.ts: -------------------------------------------------------------------------------- 1 | import { mapByPredicate } from "~/modules/array" 2 | 3 | describe("~/modules/array", () => { 4 | test("mapByPredicate", () => { 5 | const xs = [1, 2, 3, 4, 5] 6 | const ys = [1, 4, 3, 8, 5] 7 | const p: Predicate = x => x % 2 === 0 8 | const f = (n: number): number => n * 2 9 | 10 | expect(mapByPredicate(f)(p)(xs)).toEqual(ys) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /src/modules/array.ts: -------------------------------------------------------------------------------- 1 | import { Predicate, flow } from "fp-ts/lib/function" 2 | import { Eq } from "fp-ts/lib/Eq" 3 | import * as A from "fp-ts/lib/Array" 4 | 5 | export const lookupC = (i: number) => (xs: Array): Option => 6 | A.lookup(i, xs) 7 | 8 | /** 9 | * `fp-ts/lib/Array::snoc` that doesn't resolve as a `NonEmptyArray`. 10 | */ 11 | export const snoc_ = (xs: Array) => (y: T): Array => xs.concat(y) 12 | 13 | export const asArray = (xs: A | Array): Array => 14 | Array.isArray(xs) ? xs : [xs] 15 | 16 | export const elemC = (eq: Eq) => (x: A) => (ys: Array): boolean => 17 | A.elem(eq)(x, ys) 18 | 19 | export const consC = (xs: Array) => (y: A): NonEmptyArray => 20 | A.cons(y, xs) 21 | 22 | export const snocC = (xs: Array) => (y: A): NonEmptyArray => 23 | A.snoc(xs, y) 24 | 25 | export const mapByPredicate = (g: Endomorphism) => ( 26 | f: Predicate, 27 | ): Endomorphism> => flow(A.map(x => (f(x) ? g(x) : x))) 28 | -------------------------------------------------------------------------------- /src/modules/badge.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { max } from "fp-ts/lib/Ord" 3 | import { flow, constant } from "fp-ts/lib/function" 4 | import * as T from "fp-ts/lib/Task" 5 | import * as TE from "fp-ts/lib/TaskEither" 6 | import * as TO from "fp-ts-contrib/lib/TaskOption" 7 | import * as E from "fp-ts/lib/Either" 8 | import * as O from "fp-ts/lib/Option" 9 | import * as A from "fp-ts/lib/Array" 10 | import * as EO from "~/modules/eitherOption" 11 | import { browser } from "webextension-polyfill-ts" 12 | import { getBadgeDisplayOpt, BadgeDisplay } from "~/modules/settings" 13 | import { fromString } from "~/modules/url" 14 | import { URLMatch, match, ordURLMatch } from "~/modules/compare-urls" 15 | import { 16 | getBookmarksFromLocalStorage, 17 | getActiveTab, 18 | onTabActivity, 19 | } from "~/modules/comms/browser" 20 | import { snoc_ } from "~/modules/array" 21 | import { _, runTask, runIO } from "~/modules/fp" 22 | import { flip } from "fp-ts-std/Function" 23 | 24 | const setBadge = (color: string) => (text: string): IO => (): void => { 25 | browser.browserAction.setBadgeBackgroundColor({ color }) 26 | browser.browserAction.setBadgeText({ text }) 27 | } 28 | 29 | const disableBadge: IO = () => { 30 | // Empty string disables the badge 31 | browser.browserAction.setBadgeText({ text: "" }) 32 | } 33 | 34 | export const colors = { 35 | [URLMatch.Exact]: "#4286f4", 36 | [URLMatch.Domain]: "#a0c4ff", 37 | } 38 | 39 | const hrefToUrlReducer = (acc: Array, href: string): Array => 40 | pipe(fromString(href), E.fold(constant(acc), snoc_(acc))) 41 | 42 | const getBookmarksUrlsFromLocalStorage: TaskEither< 43 | Error, 44 | Option> 45 | > = pipe( 46 | getBookmarksFromLocalStorage, 47 | TE.map( 48 | O.map( 49 | flow( 50 | A.map(bm => bm.url), 51 | A.reduce([], hrefToUrlReducer), 52 | ), 53 | ), 54 | ), 55 | ) 56 | 57 | let urlState: Array = [] 58 | 59 | const syncBookmarks: Task = async () => { 60 | const bookmarkUrls = await getBookmarksUrlsFromLocalStorage() 61 | 62 | if (EO.isRightSome(bookmarkUrls)) { 63 | urlState = bookmarkUrls.right.value 64 | } 65 | } 66 | 67 | const reduceMatch = ([x, y]: [URLMatch, number]) => ( 68 | z: URLMatch, 69 | ): [URLMatch, number] => [ 70 | max(ordURLMatch)(x, z), 71 | z === URLMatch.None ? y : y + 1, 72 | ] 73 | 74 | const checkUrl = (x: URL) => (ys: Array): [URLMatch, number] => 75 | A.reduce([URLMatch.None, 0], (acc, y) => 76 | reduceMatch(acc)(match(x)(y)), 77 | )(ys) 78 | 79 | const updateBadge = ( 80 | badgeOpt: BadgeDisplay, 81 | ): Task => async (): Promise => { 82 | const urlRes = await pipe( 83 | getActiveTab, 84 | TO.chainOption(tab => O.fromNullable(tab.url)), 85 | TO.chainOption(flow(fromString, O.fromEither)), 86 | TO.map(flip(checkUrl)(urlState)), 87 | runTask, 88 | ) 89 | 90 | if (O.isSome(urlRes)) { 91 | const [result, numMatches] = urlRes.value 92 | 93 | if (badgeOpt === BadgeDisplay.None || result === URLMatch.None) { 94 | disableBadge() 95 | return 96 | } 97 | 98 | const text = badgeOpt === BadgeDisplay.WithCount ? String(numMatches) : " " 99 | 100 | switch (result) { 101 | case URLMatch.Exact: 102 | runIO(setBadge(colors[URLMatch.Exact])(text)) 103 | break 104 | 105 | case URLMatch.Domain: 106 | runIO(setBadge(colors[URLMatch.Domain])(text)) 107 | break 108 | } 109 | } 110 | } 111 | 112 | /** 113 | * Initialise backend listener that automatically listens for changes to 114 | * bookmarks in LocalStorage and window tabs, and displays a badge if there is a 115 | * match. It runs and checks immediately after instantiation before listening 116 | * for further changes. All badge functionality is encapsulated within this 117 | * function's closure. 118 | */ 119 | export const initBadgeAndListen: Task> = () => { 120 | const getBadgeOptOrDefault: Task = pipe( 121 | getBadgeDisplayOpt, 122 | T.map(EO.getOrElse(constant(BadgeDisplay.WithCount))), 123 | ) 124 | 125 | const update: Task = async () => { 126 | // We don't want to sync bookmarks at all if badge option is set to none 127 | const badgeOpt = await runTask(getBadgeOptOrDefault) 128 | if (badgeOpt !== BadgeDisplay.None) await runTask(syncBookmarks) 129 | 130 | await runTask(updateBadge(badgeOpt)) 131 | } 132 | 133 | // Update immediately on load 134 | runTask(update) 135 | 136 | // Update on tab activity 137 | onTabActivity(_(update)) 138 | 139 | // Allow updates to be triggered by callback 140 | return Promise.resolve(update) 141 | } 142 | -------------------------------------------------------------------------------- /src/modules/bookmarklet.ts: -------------------------------------------------------------------------------- 1 | import { Newtype, prism } from "newtype-ts" 2 | import * as S from "fp-ts-std/String" 3 | 4 | export const isBookmarkletCode: Predicate = S.startsWith("javascript:") 5 | 6 | export type BookmarkletCode = Newtype< 7 | { readonly BookmarkletCode: unique symbol }, 8 | string 9 | > 10 | export const { 11 | getOption: mkBookmarkletCode, 12 | reverseGet: unBookmarkletCode, 13 | } = prism(isBookmarkletCode) 14 | -------------------------------------------------------------------------------- /src/modules/bookmarks.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | filterBookmark, 3 | transform, 4 | untransform, 5 | LocalBookmark, 6 | RemoteBookmark, 7 | } from "~/modules/bookmarks" 8 | import { ParsedInputResult } from "~/modules/parse-search-input" 9 | 10 | describe("filter bookmarks with parsed input case insensitively", () => { 11 | const coolBookmark: LocalBookmark = { 12 | id: 0, 13 | title: "Cool bookmark", 14 | desc: "Some other words", 15 | url: "https://duckduckgo.com", 16 | tags: ["awesome", "supreme"], 17 | flags: 0, 18 | } 19 | 20 | const superBookmark: LocalBookmark = { 21 | id: 42, 22 | title: "Super duper website. Awesome", 23 | desc: "Legitimately fantastic", 24 | url: "https://blog.samhh.com", 25 | tags: ["impeccable"], 26 | flags: 1, 27 | } 28 | 29 | const incredibleBookmark: LocalBookmark = { 30 | id: 1337, 31 | title: "Incredibly excellent", 32 | desc: "Truly outstanding and duper awesome", 33 | url: "http://www.samhh.com", 34 | tags: ["great", "superb", "supreme"], 35 | flags: 20, 36 | } 37 | 38 | const unstoppableBookmark: LocalBookmark = { 39 | id: 99999, 40 | title: "Unstoppable", 41 | desc: "", 42 | url: "https://samhh.com/awesome", 43 | tags: [], 44 | flags: 999, 45 | } 46 | 47 | const emptyParsedInput: ParsedInputResult = { 48 | name: "", 49 | desc: [], 50 | url: [], 51 | tags: [], 52 | wildcard: [], 53 | } 54 | 55 | test("filter by title", () => { 56 | const titleFilter = filterBookmark({ 57 | ...emptyParsedInput, 58 | name: "awesome", 59 | }) 60 | 61 | expect(titleFilter(coolBookmark)).toStrictEqual(false) 62 | expect(titleFilter(superBookmark)).toStrictEqual(true) 63 | expect(titleFilter(incredibleBookmark)).toStrictEqual(false) 64 | expect(titleFilter(unstoppableBookmark)).toStrictEqual(false) 65 | }) 66 | 67 | test("filter by url", () => { 68 | const urlFilter = filterBookmark({ 69 | ...emptyParsedInput, 70 | url: ["samhh.com"], 71 | }) 72 | 73 | expect(urlFilter(coolBookmark)).toStrictEqual(false) 74 | expect(urlFilter(superBookmark)).toStrictEqual(true) 75 | expect(urlFilter(incredibleBookmark)).toStrictEqual(true) 76 | expect(urlFilter(unstoppableBookmark)).toStrictEqual(true) 77 | }) 78 | 79 | test("filter by wildcard", () => { 80 | const wildcardFilter = filterBookmark({ 81 | ...emptyParsedInput, 82 | wildcard: ["supreme", "duckduck"], 83 | }) 84 | 85 | expect(wildcardFilter(coolBookmark)).toStrictEqual(true) 86 | expect(wildcardFilter(superBookmark)).toStrictEqual(false) 87 | expect(wildcardFilter(incredibleBookmark)).toStrictEqual(false) 88 | expect(wildcardFilter(unstoppableBookmark)).toStrictEqual(false) 89 | }) 90 | }) 91 | 92 | describe("schema transform", () => { 93 | test("transform remote bookmark to local bookmark", () => { 94 | const remoteBm: RemoteBookmark = { 95 | id: 123, 96 | metadata: "meta", 97 | desc: "", 98 | url: "URL", 99 | tags: ",1,b,xx,", 100 | flags: 0, 101 | } 102 | 103 | const remoteBmLegacy: RemoteBookmark = { 104 | id: 123, 105 | metadata: "meta", 106 | desc: "", 107 | url: "URL", 108 | tags: "1,b,xx", 109 | flags: 0, 110 | } 111 | 112 | const expectedLocalBm: LocalBookmark = { 113 | id: 123, 114 | title: "meta", 115 | tags: ["1", "b", "xx"], 116 | url: "URL", 117 | desc: "", 118 | flags: 0, 119 | } 120 | 121 | expect(transform(remoteBm)).toStrictEqual(expectedLocalBm) 122 | expect(transform(remoteBmLegacy)).toStrictEqual(expectedLocalBm) 123 | }) 124 | 125 | test("untransform local bookmark to remote bookmark", () => { 126 | const localBm: LocalBookmark = { 127 | id: 123, 128 | title: "meta", 129 | tags: ["1", "b", "xx"], 130 | url: "URL", 131 | desc: "", 132 | flags: 0, 133 | } 134 | 135 | const expectedRemoteBm: RemoteBookmark = { 136 | id: 123, 137 | metadata: "meta", 138 | desc: "", 139 | url: "URL", 140 | tags: ",1,b,xx,", 141 | flags: 0, 142 | } 143 | 144 | expect(untransform(localBm)).toStrictEqual(expectedRemoteBm) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /src/modules/bookmarks.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { flow } from "fp-ts/lib/function" 3 | import { 4 | ordString, 5 | contramap, 6 | Ord, 7 | getMonoid, 8 | fromCompare, 9 | } from "fp-ts/lib/Ord" 10 | import { invert } from "fp-ts/lib/Ordering" 11 | import * as A from "fp-ts/lib/Array" 12 | import * as S from "fp-ts-std/String" 13 | import { Lens } from "monocle-ts" 14 | import { isNonEmptyString } from "newtype-ts/lib/NonEmptyString" 15 | import { formatDistanceToNow } from "date-fns" 16 | import { ParsedInputResult } from "~/modules/parse-search-input" 17 | import { URLMatch, ordURLMatch } from "~/modules/compare-urls" 18 | import { includesCI } from "~/modules/string" 19 | import { StagedBookmarksGroup } from "~/modules/staged-groups" 20 | import { delimiter } from "~/modules/buku" 21 | 22 | /* 23 | * Bookmark ready to be inserted into Buku database. 24 | */ 25 | export interface RemoteBookmarkUnsaved { 26 | metadata: string 27 | desc: string 28 | url: string 29 | tags: string 30 | flags: number 31 | } 32 | 33 | /* 34 | * Bookmark as stored in Buku database. 35 | */ 36 | export interface RemoteBookmark extends RemoteBookmarkUnsaved { 37 | id: number 38 | } 39 | 40 | /* 41 | * Bookmark ready to be saved. 42 | */ 43 | export interface LocalBookmarkUnsaved { 44 | title: string 45 | desc: string 46 | url: string 47 | tags: Array 48 | flags: number 49 | } 50 | 51 | /* 52 | * Bookmark as stored in LocalStorage. 53 | */ 54 | export interface LocalBookmark extends LocalBookmarkUnsaved { 55 | id: number 56 | } 57 | 58 | export interface LocalBookmarkWeighted extends LocalBookmark { 59 | weight: URLMatch 60 | } 61 | 62 | export const id = Lens.fromProp()("id") 63 | export const title = Lens.fromProp()("title") 64 | export const weight = Lens.fromProp()("weight") 65 | 66 | const ordTitle: Ord = contramap< 67 | string, 68 | LocalBookmarkUnsaved 69 | >(title.get)(ordString) 70 | const ordWeight: Ord = contramap< 71 | URLMatch, 72 | LocalBookmarkWeighted 73 | >(weight.get)(ordURLMatch) 74 | // The ordering of bookmark weight should be inverted for the UI 75 | const ordWeightForUI: Ord = fromCompare( 76 | flow(ordWeight.compare, invert), 77 | ) 78 | export const ordLocalBookmarkWeighted = getMonoid().concat( 79 | ordWeightForUI, 80 | ordTitle, 81 | ) 82 | 83 | /** 84 | * Filter out bookmarks that do not perfectly match the provided test. 85 | */ 86 | export const filterBookmark = ( 87 | test: ParsedInputResult, 88 | ): Predicate => (bookmark): boolean => { 89 | if (!includesCI(test.name)(bookmark.title)) return false 90 | if (test.desc.some(d => !includesCI(d)(bookmark.desc))) return false 91 | if (test.url.some(u => !includesCI(u)(bookmark.url))) return false 92 | if (test.tags.some(t => !bookmark.tags.some(tag => includesCI(t)(tag)))) 93 | return false 94 | 95 | // Ensure all wildcards match something 96 | const allWildcardsMatch = test.wildcard.every(wc => { 97 | return ( 98 | includesCI(wc)(bookmark.title) || 99 | includesCI(wc)(bookmark.desc) || 100 | includesCI(wc)(bookmark.url) || 101 | bookmark.tags.some(tag => includesCI(wc)(tag)) 102 | ) 103 | }) 104 | 105 | return allWildcardsMatch 106 | } 107 | 108 | /** 109 | * Transform a remote/native bookmark into the local format. 110 | */ 111 | export const transform = (bookmark: RemoteBookmark): LocalBookmark => ({ 112 | id: bookmark.id, 113 | title: bookmark.metadata, 114 | // Buku uses commas as delimiters including at the start and end of the 115 | // string, so filter those out 116 | tags: pipe(bookmark.tags, S.split(delimiter), A.filter(isNonEmptyString)), 117 | url: bookmark.url, 118 | desc: bookmark.desc, 119 | flags: bookmark.flags, 120 | }) 121 | 122 | /** 123 | * Transform a local bookmark into the remote/native format. 124 | */ 125 | export function untransform(bookmark: LocalBookmark): RemoteBookmark 126 | export function untransform( 127 | bookmark: LocalBookmarkUnsaved, 128 | ): RemoteBookmarkUnsaved 129 | export function untransform( 130 | bookmark: LocalBookmark | LocalBookmarkUnsaved, 131 | ): RemoteBookmark | RemoteBookmarkUnsaved { 132 | const base: RemoteBookmarkUnsaved = { 133 | metadata: bookmark.title, 134 | tags: delimiter + bookmark.tags.join(delimiter) + delimiter, 135 | url: bookmark.url, 136 | desc: bookmark.desc, 137 | flags: bookmark.flags, 138 | } 139 | 140 | const transformed: RemoteBookmark | RemoteBookmarkUnsaved = 141 | "id" in bookmark ? { ...base, id: bookmark.id } : base 142 | 143 | return transformed 144 | } 145 | 146 | export const formatStagedBookmarksGroupTitle = ( 147 | group: StagedBookmarksGroup, 148 | ): string => 149 | `${group.bookmarks.length} bookmark${ 150 | group.bookmarks.length === 1 ? "" : "s" 151 | }, ${formatDistanceToNow(group.time)} ago` 152 | -------------------------------------------------------------------------------- /src/modules/buku.ts: -------------------------------------------------------------------------------- 1 | export const delimiter = "," 2 | -------------------------------------------------------------------------------- /src/modules/command.ts: -------------------------------------------------------------------------------- 1 | import { browser } from "webextension-polyfill-ts" 2 | 3 | export enum Command { 4 | AddBookmark = "add_bookmark", 5 | StageAllTabs = "stage_all_tabs", 6 | StageWindowTabs = "stage_window_tabs", 7 | StageActiveTab = "stage_active_tab", 8 | } 9 | 10 | export const listenForCommands = ( 11 | f: (c: string) => void, 12 | ): IO => (): void => { 13 | browser.commands.onCommand.addListener(f) 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/comms/browser.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { flow, constant, constFalse, constVoid } from "fp-ts/lib/function" 3 | import { sequenceT } from "fp-ts/lib/Apply" 4 | import * as O from "fp-ts/lib/Option" 5 | import * as T from "fp-ts/lib/Task" 6 | import * as TO from "fp-ts-contrib/lib/TaskOption" 7 | import * as TE from "fp-ts/lib/TaskEither" 8 | import * as A from "fp-ts/lib/Array" 9 | import * as NEA from "fp-ts/lib/NonEmptyArray" 10 | import { browser, Tabs } from "webextension-polyfill-ts" 11 | import { BOOKMARKS_SCHEMA_VERSION } from "~/modules/config" 12 | import { runIO } from "~/modules/fp" 13 | import { 14 | sendIsomorphicMessage, 15 | IsomorphicMessage, 16 | } from "~/modules/comms/isomorphic" 17 | import { createUuid } from "~/modules/uuid" 18 | import { error } from "~/modules/error" 19 | import { LocalBookmark, LocalBookmarkUnsaved } from "~/modules/bookmarks" 20 | import { StagedBookmarksGroup } from "~/modules/staged-groups" 21 | import { BookmarkletCode, unBookmarkletCode } from "~/modules/bookmarklet" 22 | 23 | const sequenceTTE = sequenceT(TE.taskEither) 24 | 25 | export const openPopup: IO = () => { 26 | browser.browserAction.openPopup().catch(constVoid) 27 | } 28 | 29 | export const closePopup: IO = () => { 30 | window.close() 31 | } 32 | 33 | /** 34 | * As of time of writing there's no use for this function in production, but 35 | * calling it in dev can allow you to reset local storage and experience the 36 | * WebExtension as a new user would; there doesn't appear to be any clean way 37 | * of doing this within the browse either as a user or a dev. 38 | */ 39 | export const wipeLocalStorage: Task = browser.storage.local.clear 40 | 41 | const browserTabsQuery = ( 42 | x: Tabs.QueryQueryInfoType, 43 | ): TaskOption> => TO.tryCatch(() => browser.tabs.query(x)) 44 | 45 | export const getActiveTab: TaskOption = pipe( 46 | browserTabsQuery({ active: true, currentWindow: true }), 47 | TO.chainOption(A.head), 48 | ) 49 | 50 | export const getActiveWindowTabs: TaskOption> = pipe( 51 | browserTabsQuery({ currentWindow: true }), 52 | TO.chainOption(NEA.fromArray), 53 | ) 54 | 55 | export const getAllTabs: TaskOption> = pipe( 56 | browserTabsQuery({}), 57 | TO.chainOption(NEA.fromArray), 58 | ) 59 | 60 | export const onTabActivity = (cb: Lazy): void => { 61 | browser.tabs.onActivated.addListener(cb) 62 | browser.tabs.onUpdated.addListener(cb) 63 | } 64 | 65 | // The imperfect title includes check is because Firefox's href changes 66 | // according to the extension in use, if any 67 | const isNewTabPage: Predicate> = ({ 68 | url = "", 69 | title = "", 70 | }) => 71 | ["about:blank", "chrome://newtab/"].includes(url) || title.includes("New Tab") 72 | 73 | /// The active tab will not update quickly enough to allow this function to be 74 | /// called safely in a loop. Therefore, the second argument forces consumers to 75 | /// verify that this is only the first tab they're opening. 76 | export const openBookmarkInAppropriateTab = (isFirstTab: boolean) => ( 77 | url: string, 78 | ): Task => 79 | pipe( 80 | getActiveTab, 81 | T.map(O.fold(constFalse, isNewTabPage)), 82 | T.chain(canOpenInCurrentTab => (): Promise => 83 | canOpenInCurrentTab && isFirstTab 84 | ? browser.tabs.update(undefined, { url }) 85 | : browser.tabs.create({ url }), 86 | ), 87 | ) 88 | 89 | export const executeCodeInActiveTab = ( 90 | x: BookmarkletCode, 91 | ): TaskEither => 92 | TE.tryCatch( 93 | () => 94 | browser.tabs 95 | .executeScript({ code: unBookmarkletCode(x) }) 96 | .then(constVoid), 97 | constant(new Error("Failed to execute code in active tab")), 98 | ) 99 | 100 | export interface StorageState { 101 | bookmarks: Array 102 | stagedBookmarksGroups: Array 103 | bookmarksSchemaVersion: number 104 | } 105 | 106 | // TODO actually verify the return type with io-ts 107 | const getLocalStorage = ( 108 | ...ks: Array 109 | ): TaskEither>> => 110 | TE.tryCatch( 111 | () => 112 | browser.storage.local.get(ks) as Promise>>, 113 | constant(new Error("Failed to get local storage")), 114 | ) 115 | 116 | const setLocalStorage = (x: Partial): TaskEither => 117 | TE.tryCatch( 118 | () => browser.storage.local.set(x), 119 | constant(new Error("Failed to set local storage")), 120 | ) 121 | 122 | export const getBookmarksFromLocalStorage: TaskEither< 123 | Error, 124 | Option> 125 | > = pipe( 126 | getLocalStorage("bookmarks", "bookmarksSchemaVersion"), 127 | // Once upon a time we tried to store tags as a Set. Chrome's extension 128 | // storage implementation didn't like this, but Firefox did. The change was 129 | // reverted, but now the tags are sometimes still stored as a set. For some 130 | // reason. This addresses that by ensuring any tags pulled from storage will 131 | // be resolved as an array, regardless of whether they're stored as an array 132 | // or a Set. 133 | TE.chain( 134 | TE.fromPredicate( 135 | d => d.bookmarksSchemaVersion === BOOKMARKS_SCHEMA_VERSION, 136 | constant(error("Bookmark schema versions don't match")), 137 | ), 138 | ), 139 | TE.map( 140 | flow( 141 | d => O.fromNullable(d.bookmarks), 142 | O.chain(NEA.fromArray), 143 | O.map( 144 | NEA.map(bm => ({ 145 | ...bm, 146 | tags: Array.from(bm.tags), 147 | })), 148 | ), 149 | ), 150 | ), 151 | ) 152 | 153 | export const getBookmarksFromLocalStorageInfallible: Task< 154 | Array 155 | > = pipe( 156 | getBookmarksFromLocalStorage, 157 | TE.map(O.getOrElse(constant>(A.empty))), 158 | TE.getOrElse(constant(T.of>(A.empty))), 159 | ) 160 | 161 | export const saveBookmarksToLocalStorage = ( 162 | bookmarks: Array, 163 | ): TaskEither => 164 | pipe( 165 | setLocalStorage({ 166 | bookmarks, 167 | bookmarksSchemaVersion: BOOKMARKS_SCHEMA_VERSION, 168 | }), 169 | TE.chain(() => 170 | sendIsomorphicMessage(IsomorphicMessage.BookmarksUpdatedInLocalStorage), 171 | ), 172 | ) 173 | 174 | export const getStagedBookmarksGroupsFromLocalStorage: TaskEither< 175 | Error, 176 | Option> 177 | > = pipe( 178 | getLocalStorage("stagedBookmarksGroups"), 179 | TE.map(({ stagedBookmarksGroups }) => O.fromNullable(stagedBookmarksGroups)), 180 | ) 181 | 182 | export const saveStagedBookmarksGroupsToLocalStorage = ( 183 | stagedBookmarksGroups: Array, 184 | ): TaskEither => setLocalStorage({ stagedBookmarksGroups }) 185 | 186 | export const saveStagedBookmarksAsNewGroupToLocalStorage = ( 187 | newStagedBookmarks: NonEmptyArray, 188 | ): TaskEither => { 189 | const stagedBookmarksGroups = pipe( 190 | getStagedBookmarksGroupsFromLocalStorage, 191 | TE.map(O.getOrElse((): Array => [])), 192 | ) 193 | 194 | const newGroup = pipe( 195 | stagedBookmarksGroups, 196 | TE.map( 197 | flow( 198 | flow( 199 | A.map(group => group.id), 200 | createUuid, 201 | runIO, 202 | ), 203 | (id): StagedBookmarksGroup => ({ 204 | id, 205 | time: new Date().getTime(), 206 | // Assign each bookmark a generated ID, and ensure they don't clash with 207 | // one another 208 | bookmarks: newStagedBookmarks.reduce>( 209 | (acc, bm) => [ 210 | ...acc, 211 | { 212 | ...bm, 213 | id: createUuid(acc.map(b => b.id))(), 214 | }, 215 | ], 216 | [], 217 | ), 218 | }), 219 | ), 220 | ), 221 | ) 222 | 223 | return pipe( 224 | sequenceTTE(stagedBookmarksGroups, newGroup), 225 | TE.chain(([xs, y]) => 226 | setLocalStorage({ stagedBookmarksGroups: [...xs, y] }), 227 | ), 228 | ) 229 | } 230 | -------------------------------------------------------------------------------- /src/modules/comms/isomorphic.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * 4 | * I couldn't think of a better way to name this file/concept. It's for shared 5 | * communication between the frontend and backend. 6 | */ 7 | 8 | import { pipe } from "fp-ts/lib/pipeable" 9 | import { constVoid } from "fp-ts/lib/function" 10 | import * as TE from "fp-ts/lib/TaskEither" 11 | import * as A from "fp-ts/Array" 12 | import { browser } from "webextension-polyfill-ts" 13 | import { asError } from "~/modules/error" 14 | import { values } from "fp-ts-std/Record" 15 | import { eqStrict } from "fp-ts/lib/Eq" 16 | 17 | const sendMessage = (x: unknown): TaskEither => 18 | TE.tryCatch(() => browser.runtime.sendMessage(x), asError) 19 | 20 | export enum IsomorphicMessage { 21 | BookmarksUpdatedInLocalStorage = "bookmarks_updated_in_local_storage", 22 | SettingsUpdated = "settings_updated", 23 | OpenAddBookmarkCommand = "open_add_bookmark_command", 24 | } 25 | 26 | const isIsomorphicMessage: Refinement = ( 27 | x: unknown, 28 | ): x is IsomorphicMessage => 29 | pipe(values(IsomorphicMessage), A.elem(eqStrict)(x)) 30 | 31 | export const sendIsomorphicMessage = ( 32 | x: IsomorphicMessage, 33 | ): TaskEither => pipe(sendMessage(x), TE.map(constVoid)) 34 | 35 | export const listenForIsomorphicMessages = ( 36 | f: (m: IsomorphicMessage) => void, 37 | ): IO => (): void => { 38 | const g = (x: unknown): void => { 39 | if (isIsomorphicMessage(x)) f(x) 40 | } 41 | 42 | browser.runtime.onMessage.addListener(g) 43 | browser.contextMenus.onClicked.addListener(g) 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/comms/native.ts: -------------------------------------------------------------------------------- 1 | import { browser } from "webextension-polyfill-ts" 2 | import { pipe } from "fp-ts/lib/pipeable" 3 | import * as T from "fp-ts/lib/Task" 4 | import * as TE from "fp-ts/lib/TaskEither" 5 | import * as E from "fp-ts/lib/Either" 6 | import { APP_NAME, MINIMUM_BINARY_VERSION } from "~/modules/config" 7 | import { 8 | compareAgainstMinimum, 9 | SemanticVersioningComparison, 10 | } from "~/modules/semantic-versioning" 11 | import { RemoteBookmark, RemoteBookmarkUnsaved } from "~/modules/bookmarks" 12 | import { asError } from "~/modules/error" 13 | import { runTask } from "~/modules/fp" 14 | 15 | const sendNativeMessageSetup = (a: string) => ( 16 | d: unknown, 17 | ): TaskEither => 18 | TE.tryCatch(() => browser.runtime.sendNativeMessage(a, d), asError) 19 | 20 | const sendNativeMessage = sendNativeMessageSetup(APP_NAME) 21 | 22 | type CheckBinaryRes = 23 | | { outdatedBinary: true } 24 | | { cannotFindBinary: true } 25 | | { unknownError: true } 26 | 27 | interface GetBookmarksRes { 28 | bookmarksUpdated: true 29 | } 30 | 31 | interface SaveBookmarkRes { 32 | bookmarkSaved: true 33 | } 34 | 35 | interface UpdateBookmarkRes { 36 | bookmarkUpdated: true 37 | } 38 | 39 | interface DeleteBookmarkRes { 40 | bookmarkDeleted: true 41 | } 42 | 43 | export type NativeResponse = 44 | | CheckBinaryRes 45 | | GetBookmarksRes 46 | | SaveBookmarkRes 47 | | UpdateBookmarkRes 48 | | DeleteBookmarkRes 49 | 50 | export enum NativeRequestMethod { 51 | GET = "GET", 52 | OPTIONS = "OPTIONS", 53 | POST = "POST", 54 | PUT = "PUT", 55 | DELETE = "DELETE", 56 | } 57 | 58 | interface ErrResponse { 59 | success: false 60 | message: string 61 | } 62 | 63 | type NativeGETResponse = 64 | | { 65 | success: true 66 | bookmarks: Array 67 | moreAvailable: boolean 68 | } 69 | | ErrResponse 70 | 71 | interface NativeOPTIONSResponse { 72 | success: true 73 | binaryVersion: string 74 | } 75 | 76 | type NativePOSTResponse = { success: true; id: number } | { success: false } 77 | 78 | interface NativePUTResponse { 79 | success: boolean 80 | } 81 | 82 | interface NativeDELETEResponse { 83 | success: boolean 84 | } 85 | 86 | export interface NativeRequestData { 87 | GET: { offset: number } | undefined 88 | OPTIONS: undefined 89 | POST: { bookmarks: Array } 90 | PUT: { bookmarks: Array } 91 | DELETE: { bookmark_ids: Array } 92 | } 93 | 94 | export interface NativeRequestResult { 95 | GET: NativeGETResponse 96 | OPTIONS: NativeOPTIONSResponse 97 | POST: NativePOSTResponse 98 | PUT: NativePUTResponse 99 | DELETE: NativeDELETEResponse 100 | } 101 | 102 | // TODO verify these payloads with io-ts 103 | const sendMessageToNative = ( 104 | method: T, 105 | data: NativeRequestData[T], 106 | ): TaskEither => 107 | pipe( 108 | sendNativeMessage({ method, data }), 109 | TE.map(x => x as NativeRequestResult[T]), 110 | ) 111 | 112 | export enum HostVersionCheckResult { 113 | Unchecked, 114 | Okay, 115 | HostOutdated, 116 | HostTooNew, 117 | NoComms, 118 | UnknownError, 119 | } 120 | 121 | const mapVersionCheckResult = ( 122 | err: SemanticVersioningComparison, 123 | ): HostVersionCheckResult => { 124 | switch (err) { 125 | case SemanticVersioningComparison.BadVersions: 126 | return HostVersionCheckResult.UnknownError 127 | case SemanticVersioningComparison.TestTooNew: 128 | return HostVersionCheckResult.HostTooNew 129 | case SemanticVersioningComparison.TestOutdated: 130 | return HostVersionCheckResult.HostOutdated 131 | case SemanticVersioningComparison.Okay: 132 | return HostVersionCheckResult.Okay 133 | } 134 | } 135 | 136 | // Ensure binary version is equal to or newer than what we're expecting, but on 137 | // the same major version (semantic versioning) 138 | export const checkBinaryVersionFromNative: Task = pipe( 139 | sendMessageToNative(NativeRequestMethod.OPTIONS, undefined), 140 | T.map( 141 | E.fold( 142 | e => 143 | e.message.includes("host not found") 144 | ? HostVersionCheckResult.NoComms 145 | : HostVersionCheckResult.UnknownError, 146 | res => 147 | !res.success || !res.binaryVersion 148 | ? HostVersionCheckResult.UnknownError 149 | : mapVersionCheckResult( 150 | compareAgainstMinimum({ 151 | minimum: MINIMUM_BINARY_VERSION, 152 | test: res.binaryVersion, 153 | }), 154 | ), 155 | ), 156 | ), 157 | ) 158 | 159 | export const getBookmarksFromNative: TaskEither< 160 | Error, 161 | Array 162 | > = () => { 163 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 164 | const get = ( 165 | prevBookmarks: Array = [], 166 | ): TaskEither> => async () => { 167 | const resM = await runTask( 168 | sendMessageToNative(NativeRequestMethod.GET, { 169 | offset: prevBookmarks.length, 170 | }), 171 | ) 172 | if (E.isLeft(resM)) return E.left(resM.left) 173 | const res = resM.right 174 | 175 | if (!res.success) return E.left(new Error("Success key is false.")) 176 | 177 | const bookmarks = [...prevBookmarks, ...res.bookmarks] 178 | return res.moreAvailable ? get(bookmarks)() : E.right(bookmarks) 179 | } 180 | 181 | return runTask(get()) 182 | } 183 | 184 | export const saveBookmarksToNative = ( 185 | bookmarks: Array, 186 | ): TaskEither => 187 | sendMessageToNative(NativeRequestMethod.POST, { bookmarks }) 188 | 189 | export const updateBookmarksToNative = ( 190 | bookmarks: Array, 191 | ): TaskEither => 192 | sendMessageToNative(NativeRequestMethod.PUT, { bookmarks }) 193 | 194 | export const deleteBookmarksFromNative = ( 195 | bookmarkIds: Array, 196 | ): TaskEither => 197 | sendMessageToNative(NativeRequestMethod.DELETE, { bookmark_ids: bookmarkIds }) 198 | -------------------------------------------------------------------------------- /src/modules/compare-urls.test.ts: -------------------------------------------------------------------------------- 1 | import { URLMatch, match, ordURLMatch } from "~/modules/compare-urls" 2 | import { ordNumber } from "fp-ts/lib/Ord" 3 | 4 | describe("~/modules/compare-urls", () => { 5 | describe("match", () => { 6 | test("matches exact URL", () => { 7 | const url1 = new URL("https://samhh.com") 8 | const url2 = new URL("https://samhh.com/") 9 | expect(match(url1)(url2)).toBe(URLMatch.Exact) 10 | }) 11 | 12 | test("matches exact URL even if HTTP(S) protocol differs", () => { 13 | const url1 = new URL("https://samhh.com") 14 | const url2 = new URL("http://samhh.com/") 15 | expect(match(url1)(url2)).toBe(URLMatch.Exact) 16 | }) 17 | 18 | test("matches domain", () => { 19 | const url1 = new URL("https://samhh.com") 20 | const url2 = new URL("https://samhh.com/1/2/3") 21 | expect(match(url1)(url2)).toBe(URLMatch.Domain) 22 | }) 23 | 24 | test("matches subdomain as domain", () => { 25 | const url1 = new URL("https://samhh.co.uk") 26 | const url2 = new URL("https://sub.domain.samhh.co.uk") 27 | expect(match(url1)(url2)).toBe(URLMatch.Domain) 28 | }) 29 | 30 | test("does not match different domain", () => { 31 | const url1 = new URL("https://samhh.com") 32 | const url2 = new URL("https://duckduckgo.com") 33 | expect(match(url1)(url2)).toBe(URLMatch.None) 34 | }) 35 | 36 | test("does not match different TLD", () => { 37 | const url1 = new URL("https://samhh.com") 38 | const url2 = new URL("https://samhh.co.uk") 39 | expect(match(url1)(url2)).toBe(URLMatch.None) 40 | }) 41 | 42 | test("does not match URL if (non-HTTP(S)) protocols differ", () => { 43 | const url1 = new URL("https://samhh.com") 44 | const url2 = new URL("ftp://samhh.com/") 45 | expect(match(url1)(url2)).toBe(URLMatch.None) 46 | 47 | const url3 = new URL("ssh://samhh.com/") 48 | const url4 = new URL("ftp://samhh.com") 49 | expect(match(url3)(url4)).toBe(URLMatch.None) 50 | }) 51 | }) 52 | 53 | test("ordURLMatch", () => { 54 | expect(ordNumber.compare(10, 5)).toBe(1) // for reference 55 | expect(ordURLMatch.compare(URLMatch.Exact, URLMatch.Domain)).toBe(1) 56 | expect(ordURLMatch.compare(URLMatch.Domain, URLMatch.None)).toBe(1) 57 | expect(ordURLMatch.compare(URLMatch.None, URLMatch.Exact)).toBe(-1) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/modules/compare-urls.ts: -------------------------------------------------------------------------------- 1 | import { flow, not } from "fp-ts/lib/function" 2 | import { ordNumber, contramap } from "fp-ts/lib/Ord" 3 | import { eqString } from "fp-ts/lib/Eq" 4 | import { equal, mapBoth } from "~/modules/tuple" 5 | import * as A from "fp-ts/Array" 6 | import { hrefSansProtocol, isHttpOrHttps, domain } from "~/modules/url" 7 | 8 | export enum URLMatch { 9 | Exact = "exact", 10 | Domain = "domain", 11 | None = "none", 12 | } 13 | 14 | const eqS = equal(eqString) 15 | const eqHref = flow(mapBoth(hrefSansProtocol), eqS) 16 | const eqDomain = flow(mapBoth(domain), eqS) 17 | 18 | /** 19 | * Compare two URLs and determine similarity. 20 | */ 21 | export const match = (x: URL) => (y: URL): URLMatch => { 22 | const zs: [URL, URL] = [x, y] 23 | 24 | // Never match URLs with non-HTTP(S) protocols 25 | if (A.some(not(isHttpOrHttps))(zs)) return URLMatch.None 26 | 27 | // Match URLs as exact irrespective of protocol equality 28 | if (eqHref(zs)) return URLMatch.Exact 29 | 30 | // Check equality of domain (ignoring subdomain(s)) 31 | if (eqDomain(zs)) return URLMatch.Domain 32 | 33 | return URLMatch.None 34 | } 35 | 36 | export const ordURLMatch = contramap(x => { 37 | switch (x) { 38 | case URLMatch.Exact: 39 | return 2 40 | case URLMatch.Domain: 41 | return 1 42 | case URLMatch.None: 43 | return 0 44 | } 45 | })(ordNumber) 46 | -------------------------------------------------------------------------------- /src/modules/config.ts: -------------------------------------------------------------------------------- 1 | export const APP_NAME = "com.samhh.bukubrow" 2 | export const MINIMUM_BINARY_VERSION = "5.0.0" 3 | export const BOOKMARKS_SCHEMA_VERSION = 3 4 | export const MAX_BOOKMARKS_TO_RENDER = 10 5 | -------------------------------------------------------------------------------- /src/modules/connected-mount.tsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode, ReactNode } from "react" 2 | import { render } from "react-dom" 3 | import { Provider } from "react-redux" 4 | import store from "~/store" 5 | import { ThemeProvider } from "~/styles" 6 | 7 | /** 8 | * Render/mount a component with all providers supplied. 9 | */ 10 | const mountPage = (page: ReactNode): IO => (): void => { 11 | render( 12 | 13 | 14 | {page} 15 | 16 | , 17 | document.querySelector(".js-root"), 18 | ) 19 | } 20 | 21 | export default mountPage 22 | -------------------------------------------------------------------------------- /src/modules/context.ts: -------------------------------------------------------------------------------- 1 | import { browser, Menus, Tabs } from "webextension-polyfill-ts" 2 | import { pipe } from "fp-ts/lib/pipeable" 3 | import { flow } from "fp-ts/lib/function" 4 | import * as TO from "fp-ts-contrib/lib/TaskOption" 5 | import * as T from "fp-ts/lib/Task" 6 | import * as O from "fp-ts/lib/Option" 7 | import * as OT from "~/modules/optionTuple" 8 | import * as NEA from "fp-ts/lib/NonEmptyArray" 9 | import * as A from "fp-ts/lib/Array" 10 | import { 11 | getActiveTab, 12 | getAllTabs, 13 | getActiveWindowTabs, 14 | saveStagedBookmarksAsNewGroupToLocalStorage, 15 | } from "~/modules/comms/browser" 16 | import { values } from "fp-ts-std/Record" 17 | import { eqStrict } from "fp-ts/lib/Eq" 18 | import { runTask, runIOs_, runIO } from "~/modules/fp" 19 | 20 | const createContextMenuEntry = ( 21 | x: Menus.CreateCreatePropertiesType, 22 | ): IO => (): void => void browser.contextMenus.create(x) 23 | 24 | const isContextMenuEntry: Refinement = ( 25 | x, 26 | ): x is ContextMenuEntry => pipe(values(ContextMenuEntry), A.elem(eqStrict)(x)) 27 | 28 | export enum ContextMenuEntry { 29 | SendAllTabs = "send_all_tabs_to_bukubrow", 30 | SendActiveWindowTabs = "send_active_window_tabs_to_bukubrow", 31 | SendActiveTab = "send_active_tab_to_bukubrow", 32 | SendLink = "send_link_to_bukubrow", 33 | } 34 | 35 | type SufficientTab = Required> 36 | 37 | export const sendTabsToStagingArea = ( 38 | tabs: NonEmptyArray, 39 | ): TaskEither => 40 | saveStagedBookmarksAsNewGroupToLocalStorage( 41 | NEA.nonEmptyArray.map(tabs, tab => ({ 42 | title: tab.title, 43 | desc: "", 44 | url: tab.url, 45 | tags: [], 46 | flags: 0, 47 | })), 48 | ) 49 | 50 | const isSufficientTab: Refinement = ( 51 | tab, 52 | ): tab is Tabs.Tab & SufficientTab => !!tab.title && !!tab.url 53 | 54 | const sufficientTabExact: Endomorphism = ( 55 | tab: SufficientTab, 56 | ) => ({ title: tab.title, url: tab.url }) 57 | 58 | const sufficientTabsExact = flow( 59 | A.filter(isSufficientTab), 60 | A.map(sufficientTabExact), 61 | ) 62 | 63 | const createContextMenuListener = (g: (a: A) => IO) => ( 64 | f: (x: Menus.OnClickData) => TaskOption, 65 | ): IO => (): void => 66 | browser.contextMenus.onClicked.addListener(x => { 67 | runTask(f(x)).then(y => { 68 | if (O.isSome(y)) runIO(g(y.value)) 69 | }) 70 | }) 71 | 72 | export const contextClickTabs = (u: Option) => ( 73 | c: ContextMenuEntry, 74 | ): TaskOption> => { 75 | switch (c) { 76 | case ContextMenuEntry.SendAllTabs: 77 | return pipe( 78 | getAllTabs, 79 | T.map(flow(O.map(sufficientTabsExact), O.chain(NEA.fromArray))), 80 | ) 81 | 82 | case ContextMenuEntry.SendActiveWindowTabs: 83 | return pipe( 84 | getActiveWindowTabs, 85 | T.map(flow(O.map(sufficientTabsExact), O.chain(NEA.fromArray))), 86 | ) 87 | 88 | case ContextMenuEntry.SendActiveTab: 89 | return pipe( 90 | getActiveTab, 91 | T.map( 92 | flow( 93 | O.chain(tab => OT.fromNullable(tab.title, tab.url)), 94 | O.map(([title, url]) => [{ title, url }]), 95 | O.chain(NEA.fromArray), 96 | ), 97 | ), 98 | ) 99 | 100 | case ContextMenuEntry.SendLink: 101 | return pipe( 102 | u, 103 | O.map(url => [{ url, title: url }]), 104 | O.chain(NEA.fromArray), 105 | TO.fromOption, 106 | ) 107 | 108 | default: 109 | return TO.none 110 | } 111 | } 112 | 113 | const handleCtxClick = ( 114 | x: Menus.OnClickData, 115 | ): TaskOption> => 116 | pipe( 117 | x.menuItemId, 118 | O.fromPredicate(isContextMenuEntry), 119 | TO.fromOption, 120 | TO.chain(contextClickTabs(O.fromNullable(x.pageUrl))), 121 | ) 122 | 123 | /** 124 | * Initialise context menu items that each obtain various viable window tabs, 125 | * and pass those onto the callback. 126 | */ 127 | export const initContextMenusAndListen = ( 128 | cb: (tabs: NonEmptyArray) => IO, 129 | ): IO => (): void => 130 | runIOs_( 131 | createContextMenuListener(cb)(handleCtxClick), 132 | 133 | createContextMenuEntry({ 134 | id: ContextMenuEntry.SendAllTabs, 135 | title: "Send all tabs to Bukubrow", 136 | contexts: ["page"], 137 | }), 138 | 139 | createContextMenuEntry({ 140 | id: ContextMenuEntry.SendActiveWindowTabs, 141 | title: "Send window tabs to Bukubrow", 142 | contexts: ["page"], 143 | }), 144 | 145 | createContextMenuEntry({ 146 | id: ContextMenuEntry.SendActiveTab, 147 | title: "Send tab to Bukubrow", 148 | contexts: ["page"], 149 | }), 150 | 151 | createContextMenuEntry({ 152 | id: ContextMenuEntry.SendLink, 153 | title: "Send link to Bukubrow", 154 | contexts: ["link"], 155 | }), 156 | ) 157 | -------------------------------------------------------------------------------- /src/modules/eitherOption.ts: -------------------------------------------------------------------------------- 1 | import * as E from "fp-ts/lib/Either" 2 | import * as O from "fp-ts/lib/Option" 3 | 4 | export const getOrElse = (onElse: Lazy) => ( 5 | x: EitherOption, 6 | ): A => E.fold(onElse, O.getOrElse(onElse))(x) 7 | 8 | export const isRightSome = ( 9 | x: EitherOption, 10 | ): x is E.Right> => E.isRight(x) && O.isSome(x.right) 11 | -------------------------------------------------------------------------------- /src/modules/eq.ts: -------------------------------------------------------------------------------- 1 | import { eqString as eqStringB, eqNumber as eqNumberB } from "fp-ts/lib/Eq" 2 | 3 | export const eqString = (x: string): Predicate => (y): boolean => 4 | eqStringB.equals(x, y) 5 | 6 | export const eqNumber = (x: number): Predicate => (y): boolean => 7 | eqNumberB.equals(x, y) 8 | -------------------------------------------------------------------------------- /src/modules/error.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts" 2 | 3 | export const error = (x: string): Error => new Error(x) 4 | 5 | export const isError: Refinement = (x): x is Error => 6 | x instanceof Error 7 | 8 | export const asError = (x: unknown): Error => 9 | isError(x) 10 | ? x 11 | : t.string.is(x) 12 | ? error(x) 13 | : typeof x === "object" && x !== null 14 | ? error(x.toString()) 15 | : error("An unparseable error occurred") 16 | -------------------------------------------------------------------------------- /src/modules/fp.ts: -------------------------------------------------------------------------------- 1 | import { flow, constVoid } from "fp-ts/lib/function" 2 | import * as T from "fp-ts/lib/Task" 3 | import * as IO from "fp-ts/lib/IO" 4 | import * as A from "fp-ts/lib/Array" 5 | 6 | /* 7 | * Drop the return value of the provided function 8 | */ 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | export const _ = >(f: (...args: A) => any) => ( 11 | ...args: A 12 | ): void => void f(...args) 13 | 14 | export const runIO = (x: IO): A => x() 15 | 16 | export const runIO_ = _(runIO) 17 | 18 | export const runIOs = (...xs: Array>): Array => A.map(runIO)(xs) 19 | 20 | export const runIOs_ = _(runIOs) 21 | 22 | export const seqIO = A.array.sequence(IO.io) 23 | 24 | export const seqIO_ = flow(seqIO, IO.map(constVoid)) 25 | 26 | export const seqT = A.array.sequence(T.task) 27 | 28 | export const seqT_ = flow(seqT, T.map(constVoid)) 29 | 30 | export const runTask = (x: Task): Promise => x() 31 | 32 | export const runTask_ = _(runTask) 33 | -------------------------------------------------------------------------------- /src/modules/io.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts" 2 | import { PathReporter } from "io-ts/lib/PathReporter" 3 | import { pipe } from "fp-ts/lib/pipeable" 4 | import { flow } from "fp-ts/lib/function" 5 | import * as E from "fp-ts/lib/Either" 6 | import { join } from "fp-ts-std/Array" 7 | import { error } from "~/modules/error" 8 | 9 | const report = (es: t.Errors): Array => PathReporter.report(E.left(es)) 10 | 11 | // For this reason: https://github.com/gcanti/fp-ts/issues/904 12 | export const decode = (x: t.Type) => ( 13 | y: unknown, 14 | ): Either => 15 | pipe(x.decode(y), E.mapLeft(flow(report, join(", "), error))) 16 | -------------------------------------------------------------------------------- /src/modules/omnibox.ts: -------------------------------------------------------------------------------- 1 | import { browser, Omnibox } from "webextension-polyfill-ts" 2 | import { runIO, runTask } from "./fp" 3 | 4 | export const onOmniboxInput = ( 5 | f: (input: string) => Task>, 6 | ): IO => () => 7 | browser.omnibox.onInputChanged.addListener( 8 | (x, g) => void runTask(f(x)).then(g), 9 | ) 10 | 11 | export const onOmniboxSubmit = ( 12 | f: (href: string) => (d: Omnibox.OnInputEnteredDisposition) => IO, 13 | ): IO => () => 14 | browser.omnibox.onInputEntered.addListener((x, y) => runIO(f(x)(y))) 15 | -------------------------------------------------------------------------------- /src/modules/optionTuple.ts: -------------------------------------------------------------------------------- 1 | import * as O from "fp-ts/lib/Option" 2 | import { sequenceT } from "fp-ts/lib/Apply" 3 | 4 | export const optionTuple = sequenceT(O.option) 5 | 6 | export const fromNullable = ( 7 | a: A | null | undefined, 8 | b: B | null | undefined, 9 | ): OptionTuple => 10 | a === null || a === undefined || b === null || b === undefined 11 | ? O.none 12 | : O.some([a, b]) 13 | -------------------------------------------------------------------------------- /src/modules/parse-search-input.test.ts: -------------------------------------------------------------------------------- 1 | import parseSearchInput, { 2 | ParsedInputResult, 3 | } from "~/modules/parse-search-input" 4 | 5 | describe("parse search input", () => { 6 | test("correctly parse ideal input", () => { 7 | const input1 = "thing >desc #tag1 #tag2 *all :url" 8 | const result1 = parseSearchInput(input1) 9 | const expected1: ParsedInputResult = { 10 | name: "thing", 11 | desc: ["desc"], 12 | url: ["url"], 13 | tags: ["tag1", "tag2"], 14 | wildcard: ["all"], 15 | } 16 | expect(result1).toStrictEqual(expected1) 17 | 18 | const input2 = ">a b c" 19 | const result2 = parseSearchInput(input2) 20 | const expected2: ParsedInputResult = { 21 | name: "", 22 | desc: ["a b c"], 23 | url: [], 24 | tags: [], 25 | wildcard: [], 26 | } 27 | expect(result2).toStrictEqual(expected2) 28 | 29 | const input3 = "*search all" 30 | const result3 = parseSearchInput(input3) 31 | const expected3: ParsedInputResult = { 32 | name: "", 33 | desc: [], 34 | url: [], 35 | tags: [], 36 | wildcard: ["search all"], 37 | } 38 | expect(result3).toStrictEqual(expected3) 39 | }) 40 | 41 | test("correctly parse peculiar input", () => { 42 | const emptyResult: ParsedInputResult = { 43 | name: "", 44 | desc: [], 45 | url: [], 46 | tags: [], 47 | wildcard: [], 48 | } 49 | 50 | const testCases: Array<[string, Partial]> = [ 51 | ["a", { name: "a" }], 52 | ["a :", { name: "a :" }], 53 | ["a :u", { name: "a", url: ["u"] }], 54 | [String.raw`a \:u`, { name: String.raw`a \:u` }], 55 | ["a:a", { name: "a:a" }], 56 | ["a:a :", { name: "a:a :" }], 57 | ["a:a :u", { name: "a:a", url: ["u"] }], 58 | ["aa", { name: "aa" }], 59 | [" a :", { name: " a :" }], 60 | [" a :u", { name: " a", url: ["u"] }], 61 | [" :", { name: " :" }], 62 | [" :u", { url: ["u"] }], 63 | [" :u :", { url: ["u"] }], 64 | [" :u :u", { url: ["u", "u"] }], 65 | ] 66 | 67 | for (const testCase of testCases) { 68 | expect(parseSearchInput(testCase[0])).toStrictEqual({ 69 | ...emptyResult, 70 | ...testCase[1], 71 | }) 72 | } 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/modules/parse-search-input.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { constant } from "fp-ts/lib/function" 3 | import * as O from "fp-ts/lib/Option" 4 | import * as A from "fp-ts/lib/Array" 5 | import { exec, execMulti } from "~/modules/regex" 6 | 7 | const nameRegExp = /^.*?(?:(?=^[#>:*].+)|(?= +[#>:*].+)|$)/ 8 | const descsRegExp = /(?:^| )>(.+?)(?= +[#>:*]|$)/g 9 | const urlsRegExp = /(?:^| ):(.+?)(?= +[#>:*]|$)/g 10 | const tagsRegExp = /(?:^| )#(.+?)(?= +[#>:*]|$)/g 11 | const wildcardsRegExp = /(?:^| )\*(.+?)(?= +[#>:*]|$)/g 12 | 13 | export interface ParsedInputResult { 14 | name: string 15 | desc: Array 16 | url: Array 17 | tags: Array 18 | wildcard: Array 19 | } 20 | 21 | /** 22 | * Parse input string into various matches. 23 | */ 24 | const parseSearchInput = (x: string): ParsedInputResult => { 25 | const f = execMulti(x) 26 | 27 | return { 28 | name: pipe(x, exec(nameRegExp), O.chain(A.head), O.getOrElse(constant(""))), 29 | desc: f(descsRegExp), 30 | url: f(urlsRegExp), 31 | tags: f(tagsRegExp), 32 | wildcard: f(wildcardsRegExp), 33 | } 34 | } 35 | 36 | export default parseSearchInput 37 | -------------------------------------------------------------------------------- /src/modules/prism.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { Endomorphism } from "fp-ts/lib/function" 3 | import { Prism } from "monocle-ts" 4 | 5 | export const prismModifyOption = (p: Prism) => ( 6 | f: Endomorphism, 7 | ) => (x: B): Option => pipe(x, p.reverseGet, f, p.getOption) 8 | -------------------------------------------------------------------------------- /src/modules/record.ts: -------------------------------------------------------------------------------- 1 | import * as A from "fp-ts/lib/Array" 2 | import * as R from "fp-ts/lib/Record" 3 | import { getLastSemigroup } from "fp-ts/lib/Semigroup" 4 | 5 | export const fromArray = (f: (a: A) => B) => ( 6 | xs: Array, 7 | ): Record => 8 | R.fromFoldableMap(getLastSemigroup(), A.array)(xs, y => [f(y), y]) 9 | -------------------------------------------------------------------------------- /src/modules/redux.ts: -------------------------------------------------------------------------------- 1 | import { Reducer, Action } from "redux" 2 | 3 | export const curryReducer = ( 4 | f: (a: A) => (s: S) => (s: S) => S, 5 | ) => (x: S): Reducer => (s: S = x, a: A): S => f(a)(s)(s) 6 | -------------------------------------------------------------------------------- /src/modules/regex.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { constant, flow } from "fp-ts/lib/function" 3 | import * as O from "fp-ts/lib/Option" 4 | import { lookupC, snocC } from "~/modules/array" 5 | 6 | export const exec = (x: RegExp) => (y: string): Option => 7 | pipe(x.exec(y), O.fromNullable) 8 | 9 | // This works as it does without mutating `x` because `RegExp.prototype.exec` 10 | // is stateful under the hood for global and sticky `RegExp` objects 11 | export const execMulti = (x: string) => (r: RegExp): Array => { 12 | const f = exec(r) 13 | 14 | const g = (ys: Array = []): Array => 15 | pipe(f(x), O.chain(lookupC(1)), O.fold(constant(ys), flow(snocC(ys), g))) 16 | 17 | return g() 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/scroll-window.ts: -------------------------------------------------------------------------------- 1 | import { headerHeight as headerHeightInPx } from "~/containers/search-controls" 2 | const headerHeight = parseInt(headerHeightInPx) 3 | 4 | export const scrollToTop: IO = (): void => { 5 | window.scrollTo(0, 0) 6 | } 7 | 8 | export const scrollToEl = (el: HTMLElement): IO => (): void => { 9 | const elementRect = el.getBoundingClientRect() 10 | 11 | if (elementRect.top - headerHeight < 0) { 12 | window.scrollTo( 13 | 0, 14 | elementRect.top - 15 | document.documentElement.getBoundingClientRect().top - 16 | headerHeight, 17 | ) 18 | } else if (window.innerHeight - elementRect.bottom < 0) { 19 | el.scrollIntoView(false) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/semantic-versioning.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | compareAgainstMinimum, 3 | SemanticVersioningComparison, 4 | } from "~/modules/semantic-versioning" 5 | 6 | describe("compare against minimum semantic version", () => { 7 | test("correct format", () => { 8 | expect(compareAgainstMinimum({ minimum: "1.0.0", test: "1.0" })).toBe( 9 | SemanticVersioningComparison.BadVersions, 10 | ) 11 | expect(compareAgainstMinimum({ minimum: "1.0", test: "1.0.0" })).toBe( 12 | SemanticVersioningComparison.BadVersions, 13 | ) 14 | expect(compareAgainstMinimum({ minimum: "1.0.0", test: "x.0.0" })).toBe( 15 | SemanticVersioningComparison.BadVersions, 16 | ) 17 | expect( 18 | compareAgainstMinimum({ minimum: "42.51.60", test: "42.51.60" }), 19 | ).toBe(SemanticVersioningComparison.Okay) 20 | }) 21 | 22 | test("equal major version", () => { 23 | expect(compareAgainstMinimum({ minimum: "1.0.0", test: "2.0.0" })).toBe( 24 | SemanticVersioningComparison.TestTooNew, 25 | ) 26 | expect(compareAgainstMinimum({ minimum: "2.0.0", test: "1.0.0" })).toBe( 27 | SemanticVersioningComparison.TestOutdated, 28 | ) 29 | expect(compareAgainstMinimum({ minimum: "1.0.0", test: "1.0.0" })).toBe( 30 | SemanticVersioningComparison.Okay, 31 | ) 32 | }) 33 | 34 | test("equal or newer minor version", () => { 35 | expect(compareAgainstMinimum({ minimum: "1.1.0", test: "1.0.0" })).toBe( 36 | SemanticVersioningComparison.TestOutdated, 37 | ) 38 | expect(compareAgainstMinimum({ minimum: "1.0.0", test: "1.1.0" })).toBe( 39 | SemanticVersioningComparison.Okay, 40 | ) 41 | }) 42 | 43 | test("equal or newer patch version", () => { 44 | expect(compareAgainstMinimum({ minimum: "1.0.1", test: "1.0.0" })).toBe( 45 | SemanticVersioningComparison.TestOutdated, 46 | ) 47 | expect(compareAgainstMinimum({ minimum: "1.0.0", test: "1.0.1" })).toBe( 48 | SemanticVersioningComparison.Okay, 49 | ) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/modules/semantic-versioning.ts: -------------------------------------------------------------------------------- 1 | export enum SemanticVersioningComparison { 2 | Okay, 3 | BadVersions, 4 | TestOutdated, 5 | TestTooNew, 6 | } 7 | 8 | export const compareAgainstMinimum = ({ 9 | minimum, 10 | test, 11 | }: { 12 | minimum: string 13 | test: string 14 | }): SemanticVersioningComparison => { 15 | const minVer = minimum.split(".").map(str => Number(str)) 16 | const testVer = test.split(".").map(str => Number(str)) 17 | 18 | if ( 19 | minVer.length !== 3 || 20 | testVer.length !== 3 || 21 | minVer.some(num => Number.isNaN(num)) || 22 | testVer.some(num => Number.isNaN(num)) 23 | ) 24 | return SemanticVersioningComparison.BadVersions 25 | 26 | const [minMajor, minMinor, minPatch] = minVer 27 | const [testMajor, testMinor, testPatch] = testVer 28 | 29 | // Ensure equal major version 30 | if (testMajor > minMajor) return SemanticVersioningComparison.TestTooNew 31 | if (testMajor < minMajor) return SemanticVersioningComparison.TestOutdated 32 | 33 | // Ensure equal or newer minor version 34 | if (testMinor < minMinor) return SemanticVersioningComparison.TestOutdated 35 | 36 | // Ensure equal or newer patch version 37 | if (testPatch < minPatch) return SemanticVersioningComparison.TestOutdated 38 | 39 | return SemanticVersioningComparison.Okay 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/settings.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts" 2 | import { optionFromNullable } from "io-ts-types/lib/optionFromNullable" 3 | import { fromRefinement } from "io-ts-types/lib/fromRefinement" 4 | import { Lens } from "monocle-ts" 5 | import { pipe } from "fp-ts/lib/pipeable" 6 | import { flow } from "fp-ts/lib/function" 7 | import { eqString } from "fp-ts/lib/Eq" 8 | import * as T from "fp-ts/lib/Task" 9 | import * as E from "fp-ts/lib/Either" 10 | import * as O from "fp-ts/lib/Option" 11 | import { elemC } from "~/modules/array" 12 | import { values } from "fp-ts-std/Record" 13 | import { decode } from "~/modules/io" 14 | import { getSyncStorage, setSyncStorage } from "~/modules/sync" 15 | 16 | export enum Theme { 17 | Light = "light", 18 | Dark = "dark", 19 | } 20 | 21 | export const isTheme: Refinement = (arg): arg is Theme => 22 | pipe(values(Theme), elemC(eqString)(arg)) 23 | 24 | const themeCodec = fromRefinement( 25 | "theme", 26 | (x): x is Theme => t.string.is(x) && isTheme(x), 27 | ) 28 | 29 | export enum BadgeDisplay { 30 | WithCount = "with_count", 31 | WithoutCount = "without_count", 32 | None = "none", 33 | } 34 | 35 | export const isBadgeDisplayOpt: Refinement = ( 36 | arg, 37 | ): arg is BadgeDisplay => pipe(values(BadgeDisplay), elemC(eqString)(arg)) 38 | 39 | const badgeDisplayCodec = fromRefinement( 40 | "badgeDisplay", 41 | (x): x is BadgeDisplay => t.string.is(x) && isBadgeDisplayOpt(x), 42 | ) 43 | 44 | const settingsCodec = t.type({ 45 | theme: optionFromNullable(themeCodec), 46 | badgeDisplay: optionFromNullable(badgeDisplayCodec), 47 | }) 48 | 49 | export type Settings = t.TypeOf 50 | type SaveableSettings = Partial> 51 | 52 | const saveableSettings = (x: Settings): SaveableSettings => ({ 53 | theme: O.toUndefined(x.theme), 54 | badgeDisplay: O.toUndefined(x.badgeDisplay), 55 | }) 56 | 57 | const theme = Lens.fromProp()("theme") 58 | const badgeDisplay = Lens.fromProp()("badgeDisplay") 59 | 60 | export const saveSettings = flow(saveableSettings, setSyncStorage) 61 | 62 | const getSettings: TaskEither = pipe( 63 | getSyncStorage(["theme", "badgeDisplay"]), 64 | T.map(E.chain(decode(settingsCodec))), 65 | ) 66 | 67 | export const getActiveTheme: TaskEither> = pipe( 68 | getSettings, 69 | T.map(E.map(flow(theme.get, O.chain(O.fromPredicate(isTheme))))), 70 | ) 71 | 72 | export const getBadgeDisplayOpt: TaskEither> = pipe( 73 | getSettings, 74 | T.map( 75 | E.map(flow(badgeDisplay.get, O.chain(O.fromPredicate(isBadgeDisplayOpt)))), 76 | ), 77 | ) 78 | -------------------------------------------------------------------------------- /src/modules/sleep.ts: -------------------------------------------------------------------------------- 1 | const sleep = (ms: number): Task => (): Promise => 2 | new Promise(resolve => { 3 | setTimeout(resolve, ms) 4 | }) 5 | 6 | export default sleep 7 | -------------------------------------------------------------------------------- /src/modules/staged-groups.ts: -------------------------------------------------------------------------------- 1 | import { contramap, ordNumber } from "fp-ts/lib/Ord" 2 | import { Lens } from "monocle-ts" 3 | import { LocalBookmark } from "~/modules/bookmarks" 4 | 5 | export interface StagedBookmarksGroup { 6 | id: number 7 | time: number 8 | bookmarks: Array 9 | } 10 | 11 | export const id = Lens.fromProp()("id") 12 | export const time = Lens.fromProp()("time") 13 | export const bookmarks = Lens.fromProp()("bookmarks") 14 | 15 | export const ordStagedBookmarksGroup = contramap( 16 | time.get, 17 | )(ordNumber) 18 | -------------------------------------------------------------------------------- /src/modules/string.test.ts: -------------------------------------------------------------------------------- 1 | import * as O from "fp-ts/lib/Option" 2 | import { includesCI, endIndexOfAnyOf } from "~/modules/string" 3 | 4 | describe("includesCI", () => { 5 | test("string contains string case insensitively", () => { 6 | expect(includesCI("ab")("abc")).toBe(true) 7 | expect(includesCI("bC")("abc")).toBe(true) 8 | expect(includesCI("d")("abc")).toBe(false) 9 | expect(includesCI("EF")("abc")).toBe(false) 10 | }) 11 | }) 12 | 13 | describe("endIndexOfAnyOf", () => { 14 | test("returns first matching index", () => { 15 | expect(endIndexOfAnyOf("abca")(["a"])).toEqual(O.some(1)) 16 | expect(endIndexOfAnyOf("abca")(["c"])).toEqual(O.some(3)) 17 | expect(endIndexOfAnyOf("abcaA")(["A"])).toEqual(O.some(5)) 18 | expect(endIndexOfAnyOf("oh, hello there")(["hello", "oh"])).toEqual( 19 | O.some(9), 20 | ) 21 | }) 22 | 23 | test("returns None when no matches", () => { 24 | expect(endIndexOfAnyOf("abc")(["d", "e", "f"])).toEqual(O.none) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/modules/string.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import * as O from "fp-ts/lib/Option" 3 | import * as A from "fp-ts/lib/Array" 4 | import { 5 | prismNonNegativeInteger, 6 | NonNegativeInteger, 7 | } from "newtype-ts/lib/NonNegativeInteger" 8 | import { prismModifyOption } from "~/modules/prism" 9 | import { add } from "fp-ts-std/Number" 10 | 11 | /** 12 | * Includes, but case-insensitive. 13 | */ 14 | export const includesCI = (x: string): Predicate => (y): boolean => 15 | y.toLowerCase().includes(x.toLowerCase()) 16 | 17 | /** 18 | * Return ending index (index plus one) of the first match from a series of 19 | * tests. 20 | */ 21 | export const endIndexOfAnyOf = (x: string) => ( 22 | ys: Array, 23 | ): Option => 24 | pipe( 25 | ys, 26 | A.findFirstMap(y => 27 | pipe( 28 | indexOf(y)(x), 29 | O.chain(prismModifyOption(prismNonNegativeInteger)(add(y.length))), 30 | ), 31 | ), 32 | ) 33 | 34 | export const indexOf = (x: string) => (y: string): Option => 35 | pipe(y.indexOf(x), prismNonNegativeInteger.getOption) 36 | -------------------------------------------------------------------------------- /src/modules/sync.ts: -------------------------------------------------------------------------------- 1 | import * as TE from "fp-ts/lib/TaskEither" 2 | import { browser } from "webextension-polyfill-ts" 3 | import { asError } from "~/modules/error" 4 | 5 | export const getSyncStorage = ( 6 | ks: Array = [], 7 | ): TaskEither => 8 | TE.tryCatch(() => browser.storage.sync.get(ks), asError) 9 | 10 | export const setSyncStorage = ( 11 | xs: Record, 12 | ): TaskEither => 13 | TE.tryCatch(() => browser.storage.sync.set(xs), asError) 14 | -------------------------------------------------------------------------------- /src/modules/terminology.ts: -------------------------------------------------------------------------------- 1 | export const matchesTerminology = (num: number): string => { 2 | switch (num) { 3 | case 0: 4 | return "No bookmarks to open" 5 | case 1: 6 | return "Open bookmark" 7 | case 2: 8 | return "Open both bookmarks" 9 | default: 10 | return `Open all ${num} bookmarks` 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/tuple.ts: -------------------------------------------------------------------------------- 1 | import { Eq } from "fp-ts/lib/Eq" 2 | 3 | export const equal = (f: Eq): Predicate<[A, A]> => ([xa, xb]): boolean => 4 | f.equals(xa, xb) 5 | 6 | export const mapBoth = (f: (a: A) => B) => ([xa, xb]: [A, A]): [B, B] => [ 7 | f(xa), 8 | f(xb), 9 | ] 10 | -------------------------------------------------------------------------------- /src/modules/url.test.ts: -------------------------------------------------------------------------------- 1 | import { domain, hrefSansProtocol } from "~/modules/url" 2 | 3 | describe("~/modules/url", () => { 4 | test("domain", () => { 5 | expect(domain(new URL("http://www.samhh.com"))).toEqual("samhh.com") 6 | expect(domain(new URL("https://vvv.www.samhh.com"))).toEqual("samhh.com") 7 | expect(domain(new URL("http://samhh.com/abc"))).toEqual("samhh.com") 8 | }) 9 | 10 | test("hrefSansProtocol", () => { 11 | expect(hrefSansProtocol(new URL("https://samhh.com"))).toEqual("samhh.com/") 12 | expect(hrefSansProtocol(new URL("http://samhh.com/a/path.html"))).toEqual( 13 | "samhh.com/a/path.html", 14 | ) 15 | expect( 16 | hrefSansProtocol(new URL("https://subdomain.samhh.com/some/other/path")), 17 | ).toEqual("subdomain.samhh.com/some/other/path") 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/modules/url.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { flow } from "fp-ts/lib/function" 3 | import * as E from "fp-ts/lib/Either" 4 | import * as A from "fp-ts/lib/Array" 5 | import * as S from "fp-ts-std/String" 6 | import { Lens } from "monocle-ts" 7 | import { elemFlipped, join } from "fp-ts-std/Array" 8 | import { eqString } from "fp-ts/lib/Eq" 9 | 10 | export const fromString = (url: string): Either => 11 | E.tryCatch( 12 | () => new URL(url), 13 | err => (err instanceof DOMException ? err : new DOMException()), 14 | ) 15 | 16 | export const href = Lens.fromProp()("href") 17 | export const protocol = Lens.fromProp()("protocol") 18 | export const host = Lens.fromProp()("host") 19 | export const hostname = Lens.fromProp()("hostname") 20 | export const pathname = Lens.fromProp()("pathname") 21 | 22 | /** 23 | * Get the domain/host without the subdomain. 24 | */ 25 | export const domain = (x: URL): string => 26 | pipe(host.get(x), S.split("."), A.takeRight(2), join(".")) 27 | 28 | export const hrefSansProtocol = (x: URL): string => 29 | host.get(x) + pathname.get(x) 30 | 31 | export const isHttpOrHttps: Predicate = flow( 32 | protocol.get, 33 | elemFlipped(eqString)(["http:", "https:"]), 34 | ) 35 | -------------------------------------------------------------------------------- /src/modules/uuid.test.ts: -------------------------------------------------------------------------------- 1 | import { testables } from "~/modules/uuid" 2 | 3 | describe("createUuidWithMaximum", () => { 4 | test("generates unique ids that are not already taken", () => { 5 | const create = testables.createUuidWithMaximum 6 | 7 | for (let i = 0; i < 100; i++) { 8 | expect(create(2)([1])()).toEqual(2) 9 | } 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/modules/uuid.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { randomInt } from "fp-ts/lib/Random" 3 | import { eqNumber } from "fp-ts/lib/Eq" 4 | import * as IO from "fp-ts/lib/IO" 5 | import * as A from "fp-ts/lib/Array" 6 | import * as B from "fp-ts/lib/boolean" 7 | 8 | const createUuidWithMaximum = (max: number) => ( 9 | taken: Array = [], 10 | ): IO => 11 | pipe( 12 | randomInt(1, max), 13 | IO.chain(id => 14 | pipe( 15 | A.elem(eqNumber)(id, taken), 16 | B.fold( 17 | () => IO.of(id), 18 | () => createUuidWithMaximum(max)(taken), 19 | ), 20 | ), 21 | ), 22 | ) 23 | 24 | export const createUuid = createUuidWithMaximum(Number.MAX_SAFE_INTEGER) 25 | 26 | export const testables = { 27 | createUuidWithMaximum, 28 | } 29 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Bukubrow - Options 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/options.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import mount from "~/modules/connected-mount" 3 | import OptionsPage from "~/pages/options" 4 | import { runIO } from "~/modules/fp" 5 | 6 | runIO(mount()) 7 | -------------------------------------------------------------------------------- /src/pages/options.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, FC, FormEvent } from "react" 2 | import * as O from "fp-ts/lib/Option" 3 | import * as EO from "~/modules/eitherOption" 4 | import { useDispatch, useSelector } from "~/store" 5 | import { setActiveTheme } from "~/store/user/actions" 6 | import { 7 | saveSettings, 8 | getBadgeDisplayOpt, 9 | Theme, 10 | BadgeDisplay, 11 | isTheme, 12 | isBadgeDisplayOpt, 13 | } from "~/modules/settings" 14 | import { 15 | sendIsomorphicMessage, 16 | IsomorphicMessage, 17 | } from "~/modules/comms/isomorphic" 18 | import styled from "~/styles" 19 | import Button from "~/components/button" 20 | import { runTask } from "~/modules/fp" 21 | 22 | const Page = styled.main` 23 | padding: 2.5rem; 24 | ` 25 | 26 | const OptionsPage: FC = () => { 27 | const activeTheme = useSelector(state => state.user.activeTheme) 28 | const dispatch = useDispatch() 29 | 30 | const [themeOpt, setThemeOpt] = useState(activeTheme) 31 | const [badgeOpt, setBadgeOpt] = useState(BadgeDisplay.WithCount) 32 | 33 | useEffect(() => { 34 | getBadgeDisplayOpt().then(res => { 35 | if (EO.isRightSome(res)) { 36 | setBadgeOpt(res.right.value) 37 | } 38 | }) 39 | }, []) 40 | 41 | useEffect(() => { 42 | setThemeOpt(activeTheme) 43 | }, [activeTheme]) 44 | 45 | const handleThemeOptChange = (evt: FormEvent): void => { 46 | const themeOpt = evt.currentTarget.value 47 | if (!isTheme(themeOpt)) return 48 | 49 | setThemeOpt(themeOpt) 50 | } 51 | 52 | const handleBadgeOptChange = (evt: FormEvent): void => { 53 | const badgeOpt = evt.currentTarget.value 54 | if (!isBadgeDisplayOpt(badgeOpt)) return 55 | 56 | setBadgeOpt(badgeOpt) 57 | } 58 | 59 | const handleSubmit = (evt: FormEvent): void => { 60 | evt.preventDefault() 61 | 62 | dispatch(setActiveTheme(themeOpt)) 63 | runTask(sendIsomorphicMessage(IsomorphicMessage.SettingsUpdated)) 64 | runTask( 65 | saveSettings({ theme: O.some(themeOpt), badgeDisplay: O.some(badgeOpt) }), 66 | ) 67 | } 68 | 69 | return ( 70 | 71 | 72 | Theme:  73 | 77 |
78 |
79 | Badge:  80 | 85 |
86 |
87 | 88 | 89 |
90 | ) 91 | } 92 | 93 | export default OptionsPage 94 | -------------------------------------------------------------------------------- /src/store/bookmarks/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import { action } from "typesafe-actions" 4 | import { LocalBookmark } from "~/modules/bookmarks" 5 | import { StagedBookmarksGroup } from "~/modules/staged-groups" 6 | import { BookmarksActionTypes } from "./types" 7 | 8 | export const setAllBookmarks = (bookmarks: Array) => 9 | action(BookmarksActionTypes.SetAllBookmarks, bookmarks) 10 | 11 | export const setAllStagedBookmarksGroups = ( 12 | groups: Array, 13 | ) => action(BookmarksActionTypes.SetAllStagedBookmarksGroups, groups) 14 | 15 | export const deleteStagedBookmarksGroup = ( 16 | groupId: StagedBookmarksGroup["id"], 17 | ) => action(BookmarksActionTypes.DeleteStagedBookmarksGroup, groupId) 18 | 19 | export const setLimitNumRendered = (limit: boolean) => 20 | action(BookmarksActionTypes.SetLimitNumRendered, limit) 21 | 22 | export const setFocusedBookmarkIndex = (index: Option) => 23 | action(BookmarksActionTypes.SetFocusedBookmarkIndex, index) 24 | 25 | export const setBookmarkEditId = (id: Option) => 26 | action(BookmarksActionTypes.SetBookmarkEditId, id) 27 | 28 | export const setBookmarkDeleteId = (id: Option) => 29 | action(BookmarksActionTypes.SetBookmarkDeleteId, id) 30 | 31 | export const setStagedBookmarksGroupEditId = ( 32 | id: Option, 33 | ) => action(BookmarksActionTypes.SetStagedBookmarksGroupEditId, id) 34 | 35 | export const setStagedBookmarksGroupBookmarkEditId = ( 36 | id: Option, 37 | ) => action(BookmarksActionTypes.SetStagedBookmarksGroupBookmarkEditId, id) 38 | 39 | export const updateStagedBookmarksGroupBookmark = ( 40 | grpId: StagedBookmarksGroup["id"], 41 | bm: LocalBookmark, 42 | ) => 43 | action(BookmarksActionTypes.UpdateStagedBookmarksGroupBookmark, [ 44 | grpId, 45 | bm, 46 | ] as const) 47 | 48 | export const deleteStagedBookmarksGroupBookmark = ( 49 | grpId: StagedBookmarksGroup["id"], 50 | bmId: LocalBookmark["id"], 51 | ) => 52 | action(BookmarksActionTypes.DeleteStagedBookmarksGroupBookmark, [ 53 | grpId, 54 | bmId, 55 | ] as const) 56 | 57 | export const setDeleteBookmarkModalDisplay = (display: boolean) => 58 | action(BookmarksActionTypes.SetDeleteBookmarkModalDisplay, display) 59 | -------------------------------------------------------------------------------- /src/store/bookmarks/epics.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import { pipe } from "fp-ts/lib/pipeable" 4 | import { flow, constVoid, constant } from "fp-ts/lib/function" 5 | import * as T from "fp-ts/lib/Task" 6 | import * as O from "fp-ts/lib/Option" 7 | import * as E from "fp-ts/lib/Either" 8 | import * as A from "fp-ts/lib/Array" 9 | import * as R from "fp-ts/lib/Record" 10 | import { ThunkAC } from "~/store" 11 | import { 12 | setAllStagedBookmarksGroups, 13 | deleteStagedBookmarksGroup, 14 | deleteStagedBookmarksGroupBookmark, 15 | setBookmarkEditId, 16 | setBookmarkDeleteId, 17 | setFocusedBookmarkIndex, 18 | setDeleteBookmarkModalDisplay, 19 | setAllBookmarks, 20 | } from "~/store/bookmarks/actions" 21 | import { setPage } from "~/store/user/actions" 22 | import { addPermanentError } from "~/store/notices/epics" 23 | import { 24 | getWeightedLimitedFilteredBookmarks, 25 | getUnlimitedFilteredBookmarks, 26 | } from "~/store/selectors" 27 | import { 28 | saveBookmarksToNative, 29 | updateBookmarksToNative, 30 | deleteBookmarksFromNative, 31 | getBookmarksFromNative, 32 | } from "~/modules/comms/native" 33 | import { 34 | getStagedBookmarksGroupsFromLocalStorage, 35 | openBookmarkInAppropriateTab, 36 | executeCodeInActiveTab, 37 | closePopup, 38 | } from "~/modules/comms/browser" 39 | import { 40 | untransform, 41 | transform, 42 | LocalBookmark, 43 | LocalBookmarkUnsaved, 44 | } from "~/modules/bookmarks" 45 | import { StagedBookmarksGroup } from "~/modules/staged-groups" 46 | import { Page } from "~/store/user/types" 47 | import { runTask, seqT } from "~/modules/fp" 48 | import { mkBookmarkletCode } from "~/modules/bookmarklet" 49 | import { omit, values } from "fp-ts-std/Record" 50 | 51 | export const syncBookmarks = (): ThunkAC> => async dispatch => { 52 | const res = await getBookmarksFromNative() 53 | const mapped = pipe(res, E.map(A.map(transform))) 54 | 55 | if (E.isRight(mapped)) { 56 | const bms = mapped.right 57 | 58 | dispatch(setAllBookmarks(bms)) 59 | dispatch(setFocusedBookmarkIndex(bms.length ? O.some(0) : O.none)) 60 | } else { 61 | const msg = "Failed to sync bookmarks." 62 | 63 | dispatch(addPermanentError(msg)) 64 | } 65 | } 66 | 67 | export const openBookmarkAndExit = ( 68 | bmId: LocalBookmark["id"], 69 | stagedBookmarksGroupId: Option = O.none, 70 | ): ThunkAC => (_, getState) => { 71 | const { 72 | bookmarks: { bookmarks, stagedBookmarksGroups }, 73 | } = getState() 74 | 75 | const bookmark = O.fold( 76 | () => R.lookup(String(bmId), bookmarks), 77 | (grpId: StagedBookmarksGroup["id"]) => 78 | pipe( 79 | stagedBookmarksGroups, 80 | A.findFirst(grp => grp.id === grpId), 81 | O.map(grp => grp.bookmarks), 82 | O.chain(A.findFirst(bm => bm.id === bmId)), 83 | ), 84 | )(stagedBookmarksGroupId) 85 | 86 | if (O.isSome(bookmark)) { 87 | const { url } = bookmark.value 88 | const action: Task = pipe( 89 | mkBookmarkletCode(url), 90 | O.fold( 91 | () => 92 | pipe( 93 | openBookmarkInAppropriateTab(true)(url), 94 | T.chainIOK(constant(closePopup)), 95 | ), 96 | flow(executeCodeInActiveTab, T.map(constVoid)), 97 | ), 98 | ) 99 | 100 | runTask(action) 101 | } 102 | } 103 | 104 | export const openAllFilteredBookmarksAndExit = (): ThunkAC => (_, getState) => 105 | pipe( 106 | getUnlimitedFilteredBookmarks(getState()), 107 | values, 108 | A.mapWithIndex((i, { url }) => openBookmarkInAppropriateTab(i === 0)(url)), 109 | seqT, 110 | T.chainIOK(constant(closePopup)), 111 | runTask, 112 | ) 113 | 114 | export const addAllBookmarksFromStagedGroup = ( 115 | groupId: StagedBookmarksGroup["id"], 116 | ): ThunkAC> => async (dispatch, getState) => { 117 | const { 118 | bookmarks: { stagedBookmarksGroups }, 119 | } = getState() 120 | 121 | const bookmarks = pipe( 122 | stagedBookmarksGroups, 123 | A.findFirst(grp => grp.id === groupId), 124 | // Remove local ID else bookmarks will be detected as saved by 125 | // untransform overload 126 | O.map(grp => grp.bookmarks.map(omit(["id"]))), 127 | O.getOrElse>(() => []), 128 | ) 129 | 130 | await dispatch(addManyBookmarks(bookmarks)) 131 | dispatch(deleteStagedBookmarksGroup(groupId)) 132 | } 133 | 134 | export const deleteStagedBookmarksGroupBookmarkOrEntireGroup = ( 135 | grpId: StagedBookmarksGroup["id"], 136 | bmId: LocalBookmark["id"], 137 | ): ThunkAC => (dispatch, getState) => { 138 | const { 139 | bookmarks: { stagedBookmarksGroups }, 140 | } = getState() 141 | 142 | const grp = stagedBookmarksGroups.find(g => g.id === grpId) 143 | if (!grp) return 144 | 145 | if (grp.bookmarks.length === 1) { 146 | // If deleting last bookmark in group, delete entire group and return to 147 | // groups list 148 | dispatch(deleteStagedBookmarksGroup(grpId)) 149 | dispatch(setPage(Page.StagedGroupsList)) 150 | } else { 151 | // Else delete the bookmark leaving group intact 152 | dispatch(deleteStagedBookmarksGroupBookmark(grpId, bmId)) 153 | } 154 | } 155 | 156 | export const syncStagedBookmarksGroups = (): ThunkAC< 157 | Promise 158 | > => async dispatch => { 159 | const stagedBookmarksGroups = pipe( 160 | await getStagedBookmarksGroupsFromLocalStorage(), 161 | O.fromEither, 162 | O.flatten, 163 | O.getOrElse(() => [] as Array), 164 | ) 165 | 166 | dispatch(setAllStagedBookmarksGroups(stagedBookmarksGroups)) 167 | } 168 | 169 | export const addBookmark = ( 170 | bookmark: LocalBookmarkUnsaved, 171 | ): ThunkAC> => async dispatch => { 172 | await dispatch(addManyBookmarks([bookmark])) 173 | } 174 | 175 | export const addManyBookmarks = ( 176 | bookmarks: Array, 177 | ): ThunkAC> => async dispatch => { 178 | await runTask(saveBookmarksToNative(bookmarks.map(untransform))) 179 | dispatch(syncBookmarks()) 180 | 181 | dispatch(setPage(Page.Search)) 182 | } 183 | 184 | export const updateBookmark = ( 185 | bookmark: LocalBookmark, 186 | ): ThunkAC> => async dispatch => { 187 | await runTask(updateBookmarksToNative([untransform(bookmark)])) 188 | dispatch(syncBookmarks()) 189 | 190 | dispatch(setPage(Page.Search)) 191 | } 192 | 193 | export const deleteBookmark = (): ThunkAC> => async ( 194 | dispatch, 195 | getState, 196 | ) => { 197 | const { bookmarkDeleteId } = getState().bookmarks 198 | 199 | if (O.isSome(bookmarkDeleteId)) { 200 | const bookmarkId = bookmarkDeleteId.value 201 | 202 | await runTask(deleteBookmarksFromNative([bookmarkId])) 203 | dispatch(syncBookmarks()) 204 | dispatch(setDeleteBookmarkModalDisplay(false)) 205 | } 206 | } 207 | 208 | export const initiateBookmarkEdit = ( 209 | id: LocalBookmark["id"], 210 | ): ThunkAC => dispatch => { 211 | dispatch(setBookmarkEditId(O.some(id))) 212 | dispatch(setPage(Page.EditBookmark)) 213 | } 214 | 215 | export const initiateBookmarkDeletion = ( 216 | id: LocalBookmark["id"], 217 | ): ThunkAC => dispatch => { 218 | dispatch(setBookmarkDeleteId(O.some(id))) 219 | dispatch(setDeleteBookmarkModalDisplay(true)) 220 | } 221 | 222 | export const attemptFocusedBookmarkIndexIncrement = (): ThunkAC => ( 223 | dispatch, 224 | getState, 225 | ) => { 226 | const state = getState() 227 | const numFilteredBookmarks = getWeightedLimitedFilteredBookmarks(state).length 228 | 229 | return pipe( 230 | state.bookmarks.focusedBookmarkIndex, 231 | O.chain(fbmi => 232 | fbmi === numFilteredBookmarks - 1 ? O.none : O.some(fbmi + 1), 233 | ), 234 | O.fold( 235 | () => false, 236 | fbmi => { 237 | dispatch(setFocusedBookmarkIndex(O.some(fbmi))) 238 | 239 | return true 240 | }, 241 | ), 242 | ) 243 | } 244 | 245 | export const attemptFocusedBookmarkIndexDecrement = (): ThunkAC => ( 246 | dispatch, 247 | getState, 248 | ) => { 249 | const { 250 | bookmarks: { focusedBookmarkIndex: focusedBookmarkIndexMaybe }, 251 | } = getState() 252 | 253 | return pipe( 254 | focusedBookmarkIndexMaybe, 255 | O.chain(fbmi => (fbmi === 0 ? O.none : O.some(fbmi - 1))), 256 | O.fold( 257 | () => false, 258 | fbmi => { 259 | dispatch(setFocusedBookmarkIndex(O.some(fbmi))) 260 | 261 | return true 262 | }, 263 | ), 264 | ) 265 | } 266 | -------------------------------------------------------------------------------- /src/store/bookmarks/reducers.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/pipeable" 2 | import { identity, constant, flow, not } from "fp-ts/lib/function" 3 | import * as O from "fp-ts/lib/Option" 4 | import * as A from "fp-ts/lib/Array" 5 | import * as S from "fp-ts-std/String" 6 | import { ActionType } from "typesafe-actions" 7 | import * as bookmarksActions from "./actions" 8 | import { 9 | BookmarksState, 10 | BookmarksActionTypes, 11 | bookmarks, 12 | stagedBookmarksGroups, 13 | limitNumRendered, 14 | focusedBookmarkIndex, 15 | bookmarkEditId, 16 | bookmarkDeleteId, 17 | stagedBookmarksGroupEditId, 18 | stagedBookmarksGroupBookmarkEditId, 19 | displayDeleteBookmarkModal, 20 | } from "./types" 21 | import { curryReducer } from "~/modules/redux" 22 | import { id as grpId, bookmarks as grpBms } from "~/modules/staged-groups" 23 | import { mapByPredicate } from "~/modules/array" 24 | import { id as bmId, id } from "~/modules/bookmarks" 25 | import { eqNumber } from "~/modules/eq" 26 | import { fromArray } from "~/modules/record" 27 | 28 | export type BookmarksActions = ActionType 29 | 30 | const initialState: BookmarksState = { 31 | bookmarks: {}, 32 | stagedBookmarksGroups: [], 33 | limitNumRendered: true, 34 | focusedBookmarkIndex: O.none, 35 | bookmarkEditId: O.none, 36 | bookmarkDeleteId: O.none, 37 | stagedBookmarksGroupEditId: O.none, 38 | stagedBookmarksGroupBookmarkEditId: O.none, 39 | displayDeleteBookmarkModal: false, 40 | } 41 | 42 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 43 | const bookmarksReducer = curryReducer( 44 | a => _s => { 45 | switch (a.type) { 46 | case BookmarksActionTypes.SetAllBookmarks: 47 | return pipe( 48 | a.payload, 49 | fromArray(flow(id.get, S.fromNumber)), 50 | bookmarks.set, 51 | ) 52 | 53 | case BookmarksActionTypes.SetAllStagedBookmarksGroups: 54 | return stagedBookmarksGroups.set(a.payload) 55 | 56 | case BookmarksActionTypes.DeleteStagedBookmarksGroup: 57 | return stagedBookmarksGroups.modify(A.filter(x => x.id !== a.payload)) 58 | 59 | case BookmarksActionTypes.SetLimitNumRendered: 60 | return limitNumRendered.set(a.payload) 61 | 62 | case BookmarksActionTypes.SetFocusedBookmarkIndex: 63 | return focusedBookmarkIndex.set(a.payload) 64 | 65 | case BookmarksActionTypes.SetBookmarkEditId: 66 | return bookmarkEditId.set(a.payload) 67 | 68 | case BookmarksActionTypes.SetBookmarkDeleteId: 69 | return bookmarkDeleteId.set(a.payload) 70 | 71 | case BookmarksActionTypes.SetStagedBookmarksGroupEditId: 72 | return stagedBookmarksGroupEditId.set(a.payload) 73 | 74 | case BookmarksActionTypes.SetStagedBookmarksGroupBookmarkEditId: 75 | return stagedBookmarksGroupBookmarkEditId.set(a.payload) 76 | 77 | case BookmarksActionTypes.UpdateStagedBookmarksGroupBookmark: { 78 | const [newGrpId, newBm] = a.payload 79 | const eqGrp = flow(grpId.get, eqNumber(newGrpId)) 80 | const eqBm = flow(bmId.get, eqNumber(newBm.id)) 81 | 82 | return stagedBookmarksGroups.modify( 83 | mapByPredicate(grpBms.modify(mapByPredicate(constant(newBm))(eqBm)))( 84 | eqGrp, 85 | ), 86 | ) 87 | } 88 | 89 | case BookmarksActionTypes.DeleteStagedBookmarksGroupBookmark: { 90 | const [newGrpId, newBmId] = a.payload 91 | const eqGrp = flow(grpId.get, eqNumber(newGrpId)) 92 | const eqBm = flow(bmId.get, eqNumber(newBmId)) 93 | 94 | return stagedBookmarksGroups.modify( 95 | mapByPredicate(grpBms.modify(A.filter(not(eqBm))))(eqGrp), 96 | ) 97 | } 98 | 99 | case BookmarksActionTypes.SetDeleteBookmarkModalDisplay: 100 | return displayDeleteBookmarkModal.set(a.payload) 101 | 102 | default: 103 | return identity 104 | } 105 | }, 106 | )(initialState) 107 | 108 | export default bookmarksReducer 109 | -------------------------------------------------------------------------------- /src/store/bookmarks/types.ts: -------------------------------------------------------------------------------- 1 | import { Lens } from "monocle-ts" 2 | import { LocalBookmark } from "~/modules/bookmarks" 3 | import { StagedBookmarksGroup } from "~/modules/staged-groups" 4 | 5 | export interface BookmarksState { 6 | /** 7 | * The `Record` type is unsafe with index access, be sure to utilise the 8 | * helper methods from `fp-ts` for lookups. 9 | */ 10 | bookmarks: Record 11 | stagedBookmarksGroups: Array 12 | limitNumRendered: boolean 13 | focusedBookmarkIndex: Option 14 | bookmarkEditId: Option 15 | bookmarkDeleteId: Option 16 | stagedBookmarksGroupEditId: Option 17 | stagedBookmarksGroupBookmarkEditId: Option 18 | displayDeleteBookmarkModal: boolean 19 | } 20 | 21 | export const bookmarks = Lens.fromProp()("bookmarks") 22 | export const stagedBookmarksGroups = Lens.fromProp()( 23 | "stagedBookmarksGroups", 24 | ) 25 | export const limitNumRendered = Lens.fromProp()( 26 | "limitNumRendered", 27 | ) 28 | export const focusedBookmarkIndex = Lens.fromProp()( 29 | "focusedBookmarkIndex", 30 | ) 31 | export const bookmarkEditId = Lens.fromProp()("bookmarkEditId") 32 | export const bookmarkDeleteId = Lens.fromProp()( 33 | "bookmarkDeleteId", 34 | ) 35 | export const stagedBookmarksGroupEditId = Lens.fromProp()( 36 | "stagedBookmarksGroupEditId", 37 | ) 38 | export const stagedBookmarksGroupBookmarkEditId = Lens.fromProp()( 39 | "stagedBookmarksGroupBookmarkEditId", 40 | ) 41 | export const displayDeleteBookmarkModal = Lens.fromProp()( 42 | "displayDeleteBookmarkModal", 43 | ) 44 | 45 | export enum BookmarksActionTypes { 46 | SetAllBookmarks = "SET_ALL_BOOKMARKS", 47 | SetAllStagedBookmarksGroups = "SET_ALL_STAGED_BOOKMARKS_GROUPS", 48 | DeleteStagedBookmarksGroup = "DELETE_STAGED_BOOKMARKS_GROUP", 49 | SetLimitNumRendered = "SET_LIMIT_NUM_RENDERED", 50 | SetFocusedBookmarkIndex = "SET_FOCUSED_BOOKMARK_INDEX", 51 | SetBookmarkEditId = "SET_BOOKMARK_EDIT_ID", 52 | SetBookmarkDeleteId = "SET_BOOKMARK_DELETE_ID", 53 | SetStagedBookmarksGroupEditId = "SET_STAGED_BOOKMARKS_GROUP_EDIT_ID", 54 | SetStagedBookmarksGroupBookmarkEditId = "SET_STAGED_BOOKMARKS_GROUP_BOOKMARK_EDIT_ID", 55 | UpdateStagedBookmarksGroupBookmark = "UPDATE_STAGED_BOOKMARKS_GROUP_BOOKMARK", 56 | DeleteStagedBookmarksGroupBookmark = "DELETE_STAGED_BOOKMARKS_GROUP_BOOKMARK", 57 | SetDeleteBookmarkModalDisplay = "SET_DELETE_BOOKMARK_MODAL_DISPLAY", 58 | } 59 | -------------------------------------------------------------------------------- /src/store/browser/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import { action } from "typesafe-actions" 4 | import { BrowserActionTypes } from "./types" 5 | 6 | export const setPageMeta = (pageTitle: string, pageUrl: string) => 7 | action(BrowserActionTypes.SyncBrowser, { pageTitle, pageUrl }) 8 | -------------------------------------------------------------------------------- /src/store/browser/epics.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import * as O from "fp-ts/lib/Option" 4 | import * as OT from "~/modules/optionTuple" 5 | import { getActiveTab } from "~/modules/comms/browser" 6 | import { ThunkAC } from "~/store" 7 | import { setPageMeta } from "./actions" 8 | 9 | export const syncBrowserInfo = (): ThunkAC> => async dispatch => { 10 | const tab = O.option.chain(await getActiveTab(), tab => 11 | OT.fromNullable(tab.title, tab.url), 12 | ) 13 | 14 | if (O.isSome(tab)) { 15 | const [title, url] = tab.value 16 | dispatch(setPageMeta(title, url)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/store/browser/reducers.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from "typesafe-actions" 2 | import { identity, constant } from "fp-ts/lib/function" 3 | import * as browserActions from "./actions" 4 | import { BrowserState, BrowserActionTypes } from "./types" 5 | import { curryReducer } from "~/modules/redux" 6 | 7 | export type BrowserActions = ActionType 8 | 9 | const initialState: BrowserState = { 10 | pageTitle: "", 11 | pageUrl: "", 12 | } 13 | 14 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 15 | const browserReducer = curryReducer(a => _s => { 16 | switch (a.type) { 17 | case BrowserActionTypes.SyncBrowser: 18 | return constant(a.payload) 19 | 20 | default: 21 | return identity 22 | } 23 | })(initialState) 24 | 25 | export default browserReducer 26 | -------------------------------------------------------------------------------- /src/store/browser/types.ts: -------------------------------------------------------------------------------- 1 | import { Lens } from "monocle-ts" 2 | 3 | export interface BrowserState { 4 | pageTitle: string 5 | pageUrl: string 6 | } 7 | 8 | export const pageTitle = Lens.fromProp()("pageTitle") 9 | export const pageUrl = Lens.fromProp()("pageUrl") 10 | 11 | export enum BrowserActionTypes { 12 | SyncBrowser = "SYNC_BROWSER", 13 | } 14 | -------------------------------------------------------------------------------- /src/store/epics.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import { constant } from "fp-ts/lib/function" 4 | import * as O from "fp-ts/lib/Option" 5 | import * as EO from "~/modules/eitherOption" 6 | import { onTabActivity } from "~/modules/comms/browser" 7 | import { 8 | checkBinaryVersionFromNative, 9 | HostVersionCheckResult, 10 | } from "~/modules/comms/native" 11 | import { getActiveTheme, Theme } from "~/modules/settings" 12 | import { ThunkAC, initAutoStoreSync } from "~/store" 13 | import { 14 | setLimitNumRendered, 15 | setFocusedBookmarkIndex, 16 | } from "~/store/bookmarks/actions" 17 | import { setActiveTheme, hostCheckResult, setPage } from "~/store/user/actions" 18 | import { setSearchFilter } from "~/store/input/actions" 19 | import { addPermanentError } from "~/store/notices/epics" 20 | import { 21 | syncStagedBookmarksGroups, 22 | syncBookmarks, 23 | } from "~/store/bookmarks/epics" 24 | import { syncBrowserInfo } from "~/store/browser/epics" 25 | import { getWeightedLimitedFilteredBookmarks } from "~/store/selectors" 26 | import { 27 | listenForIsomorphicMessages, 28 | IsomorphicMessage, 29 | } from "~/modules/comms/isomorphic" 30 | import { Page } from "~/store/user/types" 31 | import { runIO } from "~/modules/fp" 32 | 33 | const hostCheckErrMsg = (x: HostVersionCheckResult): Option => { 34 | switch (x) { 35 | case HostVersionCheckResult.HostTooNew: 36 | return O.some("The WebExtension is outdated relative to the host") 37 | case HostVersionCheckResult.HostOutdated: 38 | return O.some("The host is outdated") 39 | case HostVersionCheckResult.NoComms: 40 | return O.some("The host could not be found") 41 | case HostVersionCheckResult.UnknownError: 42 | return O.some("An unknown error occurred") 43 | default: 44 | return O.none 45 | } 46 | } 47 | 48 | const onLoadPostComms = (): ThunkAC => dispatch => { 49 | // Store sync initialised here to prevent race condition with staged groups 50 | initAutoStoreSync() 51 | dispatch(syncBookmarks()) 52 | dispatch(syncStagedBookmarksGroups()) 53 | 54 | // Sync browser info once now on load and then again whenever there's any tab 55 | // activity 56 | dispatch(syncBrowserInfo()) 57 | onTabActivity(() => { 58 | dispatch(syncBrowserInfo()) 59 | }) 60 | } 61 | 62 | export const onLoad = (): ThunkAC> => async dispatch => { 63 | runIO( 64 | listenForIsomorphicMessages(x => { 65 | switch (x) { 66 | case IsomorphicMessage.OpenAddBookmarkCommand: 67 | dispatch(setPage(Page.AddBookmark)) 68 | return 69 | } 70 | }), 71 | ) 72 | 73 | getActiveTheme() 74 | .then(EO.getOrElse(constant(Theme.Light))) 75 | .then(theme => { 76 | dispatch(setActiveTheme(theme)) 77 | }) 78 | 79 | const res = await checkBinaryVersionFromNative() 80 | dispatch(hostCheckResult(res)) 81 | 82 | if (res === HostVersionCheckResult.Okay) dispatch(onLoadPostComms()) 83 | 84 | const err = hostCheckErrMsg(res) 85 | if (O.isSome(err)) dispatch(addPermanentError(err.value)) 86 | } 87 | 88 | export const setSearchFilterWithResets = (filter: string): ThunkAC => ( 89 | dispatch, 90 | getState, 91 | ) => { 92 | dispatch(setSearchFilter(filter)) 93 | dispatch(setLimitNumRendered(true)) 94 | 95 | const filteredBookmarks = getWeightedLimitedFilteredBookmarks(getState()) 96 | 97 | dispatch( 98 | setFocusedBookmarkIndex(filteredBookmarks.length ? O.some(0) : O.none), 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from "redux" 2 | import thunk, { ThunkMiddleware, ThunkAction } from "redux-thunk" 3 | import { composeWithDevTools } from "remote-redux-devtools" 4 | import { 5 | useSelector as useSelectorUntyped, 6 | useDispatch as useDispatchRaw, 7 | TypedUseSelectorHook, 8 | } from "react-redux" 9 | import { onLoad } from "~/store/epics" 10 | import { 11 | saveStagedBookmarksGroupsToLocalStorage, 12 | saveBookmarksToLocalStorage, 13 | } from "~/modules/comms/browser" 14 | import { runTask } from "~/modules/fp" 15 | import { values } from "fp-ts-std/Record" 16 | 17 | import bookmarksReducer, { BookmarksActions } from "./bookmarks/reducers" 18 | import browserReducer, { BrowserActions } from "./browser/reducers" 19 | import inputReducer, { InputActions } from "./input/reducers" 20 | import noticesReducer, { NoticesActions } from "./notices/reducers" 21 | import userReducer, { UserActions } from "./user/reducers" 22 | 23 | const rootReducer = combineReducers({ 24 | bookmarks: bookmarksReducer, 25 | browser: browserReducer, 26 | input: inputReducer, 27 | notices: noticesReducer, 28 | user: userReducer, 29 | }) 30 | 31 | export type AppState = ReturnType 32 | 33 | type AllActions = 34 | | BookmarksActions 35 | | BrowserActions 36 | | InputActions 37 | | NoticesActions 38 | | UserActions 39 | 40 | export type ThunkAC = ThunkAction 41 | 42 | const middleware = applyMiddleware( 43 | thunk as ThunkMiddleware, 44 | ) 45 | const store = createStore( 46 | rootReducer, 47 | // Assertion fixes devtools compose breaking thunk middleware type override 48 | composeWithDevTools(middleware) as typeof middleware, 49 | ) 50 | 51 | store.dispatch(onLoad()) 52 | 53 | // Re-export appropriately typed hooks 54 | export const useSelector: TypedUseSelectorHook = useSelectorUntyped 55 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 56 | export const useDispatch = () => useDispatchRaw() 57 | 58 | // Keep store in sync with local cache 59 | export const initAutoStoreSync = (): void => { 60 | store.subscribe(() => { 61 | const { 62 | bookmarks: { bookmarks, stagedBookmarksGroups }, 63 | } = store.getState() 64 | 65 | runTask(saveBookmarksToLocalStorage(values(bookmarks))) 66 | runTask(saveStagedBookmarksGroupsToLocalStorage(stagedBookmarksGroups)) 67 | }) 68 | } 69 | 70 | export default store 71 | -------------------------------------------------------------------------------- /src/store/input/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import { action } from "typesafe-actions" 4 | import { InputActionTypes } from "./types" 5 | 6 | export const setSearchFilter = (filter: string) => 7 | action(InputActionTypes.SetSearchFilter, filter) 8 | -------------------------------------------------------------------------------- /src/store/input/reducers.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from "typesafe-actions" 2 | import { identity } from "fp-ts/lib/function" 3 | import * as inputActions from "./actions" 4 | import { InputState, InputActionTypes, searchFilter } from "./types" 5 | import { curryReducer } from "~/modules/redux" 6 | 7 | export type InputActions = ActionType 8 | 9 | const initialState: InputState = { 10 | searchFilter: "", 11 | } 12 | 13 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 14 | const inputReducer = curryReducer(a => _s => { 15 | switch (a.type) { 16 | case InputActionTypes.SetSearchFilter: 17 | return searchFilter.set(a.payload) 18 | 19 | default: 20 | return identity 21 | } 22 | })(initialState) 23 | 24 | export default inputReducer 25 | -------------------------------------------------------------------------------- /src/store/input/types.ts: -------------------------------------------------------------------------------- 1 | import { Lens } from "monocle-ts" 2 | 3 | export interface InputState { 4 | searchFilter: string 5 | } 6 | 7 | export const searchFilter = Lens.fromProp()("searchFilter") 8 | 9 | export enum InputActionTypes { 10 | SetSearchFilter = "SET_SEARCH_FILTER", 11 | } 12 | -------------------------------------------------------------------------------- /src/store/notices/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import { action } from "typesafe-actions" 4 | import { NoticesActionTypes, NoticeId, NoticeMsg } from "./types" 5 | 6 | export const addError = (id: NoticeId, msg: NoticeMsg) => 7 | action(NoticesActionTypes.AddError, { key: id, value: msg }) 8 | 9 | export const deleteError = (id: NoticeId) => 10 | action(NoticesActionTypes.DeleteError, id) 11 | -------------------------------------------------------------------------------- /src/store/notices/epics.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import sleep from "~/modules/sleep" 4 | import { ThunkAC } from "~/store" 5 | import { addError, deleteError } from "./actions" 6 | import { NoticeId, NoticeMsg } from "./types" 7 | import { createUuid } from "~/modules/uuid" 8 | import { runTask } from "~/modules/fp" 9 | 10 | export const addPermanentError = (errorMsg: NoticeMsg): ThunkAC => ( 11 | dispatch, 12 | getState, 13 | ) => { 14 | const errorIds = Object.keys(getState().notices.errors) 15 | const newId = String(createUuid(errorIds.map(Number))) 16 | 17 | dispatch(addError(newId, errorMsg)) 18 | 19 | return newId 20 | } 21 | 22 | export const addTransientError = ( 23 | errorMsg: NoticeMsg, 24 | timeout = 5000, 25 | ): ThunkAC> => async dispatch => { 26 | const id = dispatch(addPermanentError(errorMsg)) 27 | 28 | await runTask(sleep(timeout)) 29 | 30 | dispatch(deleteError(id)) 31 | } 32 | -------------------------------------------------------------------------------- /src/store/notices/reducers.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from "typesafe-actions" 2 | import { identity } from "fp-ts/lib/function" 3 | import * as R from "fp-ts/lib/Record" 4 | import * as noticesActions from "./actions" 5 | import { NoticesState, NoticesActionTypes, errors } from "./types" 6 | import { curryReducer } from "~/modules/redux" 7 | 8 | export type NoticesActions = ActionType 9 | 10 | const initialState: NoticesState = { 11 | errors: {}, 12 | } 13 | 14 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 15 | const noticesReducer = curryReducer(a => _s => { 16 | switch (a.type) { 17 | case NoticesActionTypes.AddError: 18 | return errors.modify(R.insertAt(a.payload.key, a.payload.value)) 19 | 20 | case NoticesActionTypes.DeleteError: 21 | return errors.modify(R.deleteAt(a.payload)) 22 | 23 | default: 24 | return identity 25 | } 26 | })(initialState) 27 | 28 | export default noticesReducer 29 | -------------------------------------------------------------------------------- /src/store/notices/types.ts: -------------------------------------------------------------------------------- 1 | import { Lens } from "monocle-ts" 2 | import { AppState } from "~/store/" 3 | 4 | export type NoticeMsg = string 5 | export type NoticeId = string 6 | 7 | export interface NoticesState { 8 | errors: Record 9 | } 10 | 11 | export const noticesL = Lens.fromProp()("notices") 12 | 13 | export const errors = Lens.fromProp()("errors") 14 | export const errorsL = noticesL.compose(errors) 15 | 16 | export enum NoticesActionTypes { 17 | AddError = "ADD_ERROR", 18 | DeleteError = "DELETE_ERROR", 19 | } 20 | -------------------------------------------------------------------------------- /src/store/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import { pipe } from "fp-ts/lib/pipeable" 4 | import { constant, identity, flow } from "fp-ts/lib/function" 5 | import * as O from "fp-ts/lib/Option" 6 | import * as OT from "~/modules/optionTuple" 7 | import * as A from "fp-ts/lib/Array" 8 | import * as R from "fp-ts/lib/Record" 9 | import { createSelector } from "reselect" 10 | import { AppState } from "~/store" 11 | import parseSearchInput from "~/modules/parse-search-input" 12 | import { 13 | filterBookmark, 14 | ordLocalBookmarkWeighted, 15 | LocalBookmark, 16 | LocalBookmarkWeighted, 17 | } from "~/modules/bookmarks" 18 | import { MAX_BOOKMARKS_TO_RENDER } from "~/modules/config" 19 | import { URLMatch, match } from "~/modules/compare-urls" 20 | import { fromString } from "~/modules/url" 21 | import { 22 | StagedBookmarksGroup, 23 | ordStagedBookmarksGroup, 24 | bookmarks, 25 | } from "~/modules/staged-groups" 26 | import { values } from "fp-ts-std/Record" 27 | import * as S from "fp-ts-std/String" 28 | 29 | const addBookmarkWeight = (activeTabURL: Option) => ( 30 | bookmark: LocalBookmark, 31 | ): LocalBookmarkWeighted => ({ 32 | ...bookmark, 33 | weight: pipe( 34 | OT.optionTuple(activeTabURL, pipe(bookmark.url, fromString, O.fromEither)), 35 | O.map(([activeURL, bmURL]) => match(activeURL)(bmURL)), 36 | O.getOrElse(constant(URLMatch.None)), 37 | ), 38 | }) 39 | 40 | const withWeight = flow(fromString, O.fromEither, addBookmarkWeight) 41 | 42 | const getBookmarks = (state: AppState) => state.bookmarks.bookmarks 43 | const getFocusedBookmarkIndex = (state: AppState) => 44 | state.bookmarks.focusedBookmarkIndex 45 | const getBookmarkEditId = (state: AppState) => state.bookmarks.bookmarkEditId 46 | const getBookmarkDeleteId = (state: AppState) => 47 | state.bookmarks.bookmarkDeleteId 48 | const getLimitNumRendered = (state: AppState) => 49 | state.bookmarks.limitNumRendered 50 | const getStagedGroups = (state: AppState) => 51 | state.bookmarks.stagedBookmarksGroups 52 | const getStagedGroupEditId = (state: AppState) => 53 | state.bookmarks.stagedBookmarksGroupEditId 54 | const getStagedGroupBookmarkEditId = (state: AppState) => 55 | state.bookmarks.stagedBookmarksGroupBookmarkEditId 56 | const getSearchFilter = (state: AppState) => state.input.searchFilter 57 | const getActiveTabHref = (state: AppState) => state.browser.pageUrl 58 | 59 | /** 60 | * Parse search input/filter. 61 | */ 62 | export const getParsedFilter = createSelector(getSearchFilter, parseSearchInput) 63 | 64 | /** 65 | * Filter all bookmarks by search filter. 66 | */ 67 | export const getUnlimitedFilteredBookmarks = createSelector( 68 | getBookmarks, 69 | getParsedFilter, 70 | (bookmarks, inputFilter) => 71 | pipe(bookmarks, R.filter(filterBookmark(inputFilter))), 72 | ) 73 | 74 | /** 75 | * Filter all bookmarks by search filter, apply weighting, and sort them by 76 | * weight. 77 | */ 78 | export const getWeightedUnlimitedFilteredBookmarks = createSelector( 79 | getUnlimitedFilteredBookmarks, 80 | getActiveTabHref, 81 | (bookmarks, activeTabHref) => 82 | pipe( 83 | bookmarks, 84 | R.map(withWeight(activeTabHref)), 85 | values, 86 | A.sort(ordLocalBookmarkWeighted), 87 | ), 88 | ) 89 | 90 | /** 91 | * Filter all bookmarks by search filter, apply weighting, sort them by weight, 92 | * and potentially return only a limited subset of them according to the store. 93 | */ 94 | export const getWeightedLimitedFilteredBookmarks = createSelector( 95 | getWeightedUnlimitedFilteredBookmarks, 96 | getLimitNumRendered, 97 | (bookmarks, limitNumRendered) => 98 | pipe( 99 | bookmarks, 100 | limitNumRendered ? A.takeLeft(MAX_BOOKMARKS_TO_RENDER) : identity, 101 | ), 102 | ) 103 | 104 | /** 105 | * Return the number of bookmarks matching the filter being removed by the limit. 106 | */ 107 | export const getNumFilteredUnrenderedBookmarks = createSelector( 108 | getUnlimitedFilteredBookmarks, 109 | getWeightedLimitedFilteredBookmarks, 110 | (us, ls) => Math.max(0, R.size(us) - ls.length), 111 | ) 112 | 113 | export const getFocusedBookmark = createSelector( 114 | getWeightedLimitedFilteredBookmarks, 115 | getFocusedBookmarkIndex, 116 | (bookmarks, focusedId) => 117 | pipe( 118 | focusedId, 119 | O.chain(i => A.lookup(i, bookmarks)), 120 | ), 121 | ) 122 | 123 | export const getBookmarkToEdit = createSelector( 124 | getBookmarks, 125 | getBookmarkEditId, 126 | (bookmarks, editId) => 127 | pipe( 128 | editId, 129 | O.map(S.fromNumber), 130 | O.chain(eid => R.lookup(eid, bookmarks)), 131 | ), 132 | ) 133 | 134 | export const getBookmarkToDelete = createSelector( 135 | getBookmarks, 136 | getBookmarkDeleteId, 137 | (bookmarks, deleteId) => 138 | pipe( 139 | deleteId, 140 | O.map(S.fromNumber), 141 | O.chain(did => R.lookup(did, bookmarks)), 142 | ), 143 | ) 144 | 145 | export const getSortedStagedGroups = createSelector( 146 | getStagedGroups, 147 | flow(A.sort(ordStagedBookmarksGroup), A.reverse), 148 | ) 149 | 150 | export const getStagedGroupToEdit = createSelector( 151 | getStagedGroups, 152 | getStagedGroupEditId, 153 | (groups, editId) => 154 | pipe( 155 | editId, 156 | O.chain(eid => 157 | A.findFirst(grp => grp.id === eid)(groups), 158 | ), 159 | ), 160 | ) 161 | 162 | /** 163 | * Get weighted bookmarks from the staging area group selected for editing. 164 | */ 165 | export const getStagedGroupToEditWeightedBookmarks = createSelector( 166 | getStagedGroupToEdit, 167 | getActiveTabHref, 168 | (group, activeTabHref) => 169 | pipe(group, O.map(flow(bookmarks.get, A.map(withWeight(activeTabHref))))), 170 | ) 171 | 172 | export const getStagedGroupBookmarkToEdit = createSelector( 173 | getStagedGroupToEdit, 174 | getStagedGroupBookmarkEditId, 175 | (group, editId) => 176 | pipe( 177 | OT.optionTuple(group, editId), 178 | O.chain(([{ bookmarks }, eid]) => 179 | A.findFirst((bm: LocalBookmark) => bm.id === eid)(bookmarks), 180 | ), 181 | ), 182 | ) 183 | -------------------------------------------------------------------------------- /src/store/user/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | 3 | import { action } from "typesafe-actions" 4 | import { UserActionTypes, Theme, Page } from "./types" 5 | import { HostVersionCheckResult } from "~/modules/comms/native" 6 | 7 | export const hostCheckResult = (comms: HostVersionCheckResult) => 8 | action(UserActionTypes.HostCheckResult, comms) 9 | 10 | export const setActiveTheme = (theme: Theme) => 11 | action(UserActionTypes.SetActiveTheme, theme) 12 | 13 | export const setDisplayOpenAllBookmarksConfirmation = (display: boolean) => 14 | action(UserActionTypes.SetDisplayOpenAllBookmarksConfirmation, display) 15 | 16 | export const setPage = (page: Page) => action(UserActionTypes.SetPage, page) 17 | -------------------------------------------------------------------------------- /src/store/user/reducers.ts: -------------------------------------------------------------------------------- 1 | import { ActionType } from "typesafe-actions" 2 | import * as userActions from "./actions" 3 | import { 4 | UserState, 5 | UserActionTypes, 6 | Theme, 7 | Page, 8 | comms, 9 | activeTheme, 10 | displayOpenAllBookmarksConfirmation, 11 | page, 12 | } from "./types" 13 | import { identity } from "fp-ts/lib/function" 14 | import { curryReducer } from "~/modules/redux" 15 | import { HostVersionCheckResult } from "~/modules/comms/native" 16 | 17 | export type UserActions = ActionType 18 | 19 | const initialState: UserState = { 20 | comms: HostVersionCheckResult.Unchecked, 21 | activeTheme: Theme.Light, 22 | displayOpenAllBookmarksConfirmation: false, 23 | page: Page.Search, 24 | } 25 | 26 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 27 | const userReducer = curryReducer(a => _s => { 28 | switch (a.type) { 29 | case UserActionTypes.HostCheckResult: 30 | return comms.set(a.payload) 31 | 32 | case UserActionTypes.SetActiveTheme: 33 | return activeTheme.set(a.payload) 34 | 35 | case UserActionTypes.SetDisplayOpenAllBookmarksConfirmation: 36 | return displayOpenAllBookmarksConfirmation.set(a.payload) 37 | 38 | case UserActionTypes.SetPage: 39 | return page.set(a.payload) 40 | 41 | default: 42 | return identity 43 | } 44 | })(initialState) 45 | 46 | export default userReducer 47 | -------------------------------------------------------------------------------- /src/store/user/types.ts: -------------------------------------------------------------------------------- 1 | import { Lens } from "monocle-ts" 2 | import { AppState } from "~/store/" 3 | import { HostVersionCheckResult } from "~/modules/comms/native" 4 | import { Theme } from "~/modules/settings" 5 | export { Theme } 6 | 7 | export interface UserState { 8 | comms: HostVersionCheckResult 9 | activeTheme: Theme 10 | displayOpenAllBookmarksConfirmation: boolean 11 | page: Page 12 | } 13 | 14 | export const userL = Lens.fromProp()("user") 15 | 16 | export const comms = Lens.fromProp()("comms") 17 | export const activeTheme = Lens.fromProp()("activeTheme") 18 | export const displayOpenAllBookmarksConfirmation = Lens.fromProp()( 19 | "displayOpenAllBookmarksConfirmation", 20 | ) 21 | export const page = Lens.fromProp()("page") 22 | 23 | export const commsL = userL.compose(comms) 24 | 25 | export enum UserActionTypes { 26 | HostCheckResult = "HOST_CHECK_RESULT", 27 | SetActiveTheme = "SET_ACTIVE_THEME", 28 | SetDisplayOpenAllBookmarksConfirmation = "SET_OPEN_ALL_BOOKMARKS_CONFIRMATION", 29 | SetPage = "SET_PAGE", 30 | } 31 | 32 | export enum Page { 33 | Search, 34 | AddBookmark, 35 | EditBookmark, 36 | StagedGroupsList, 37 | StagedGroup, 38 | EditStagedBookmark, 39 | } 40 | -------------------------------------------------------------------------------- /src/styles.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import { useSelector } from "react-redux" 3 | import { AppState } from "~/store" 4 | import * as styledComponents from "styled-components" 5 | import styledSanitize from "styled-sanitize" 6 | import { Theme } from "~/modules/settings" 7 | 8 | const { 9 | default: styled, 10 | css, 11 | createGlobalStyle, 12 | keyframes, 13 | } = (styledComponents as unknown) as styledComponents.ThemedStyledComponentsModule 14 | 15 | const GlobalStyles = createGlobalStyle` 16 | ${styledSanitize} 17 | 18 | html { 19 | font-size: 62.5%; 20 | } 21 | 22 | body { 23 | max-width: 100%; 24 | width: 500px; 25 | min-height: 100vh; 26 | margin: 0; 27 | font-size: 1.6rem; 28 | font-family: sans-serif; 29 | background: ${(props): string => props.theme.backgroundColor}; 30 | color: ${(props): string => props.theme.textColor}; 31 | } 32 | ` 33 | 34 | interface StyledTheme { 35 | borderRadius: string 36 | backgroundColor: string 37 | backgroundColorOffset: string 38 | backgroundColorOffsetOffset: string 39 | textColor: string 40 | textColorOffset: string 41 | } 42 | 43 | const sharedTheme = { 44 | borderRadius: "3px", 45 | textColorOffset: "#888", 46 | } 47 | 48 | const lightTheme: StyledTheme = { 49 | ...sharedTheme, 50 | backgroundColor: "white", 51 | backgroundColorOffset: "#eee", 52 | backgroundColorOffsetOffset: "#ddd", 53 | textColor: "#282828", 54 | } 55 | 56 | const darkTheme: StyledTheme = { 57 | ...sharedTheme, 58 | backgroundColor: "#282828", 59 | backgroundColorOffset: "#383838", 60 | backgroundColorOffsetOffset: "#484848", 61 | textColor: "#eee", 62 | } 63 | 64 | const ThemeProvider: FC = ({ children }) => { 65 | const theme = useSelector((state: AppState) => state.user.activeTheme) 66 | 67 | return ( 68 | { 70 | switch (theme) { 71 | case Theme.Light: 72 | return lightTheme 73 | case Theme.Dark: 74 | return darkTheme 75 | } 76 | }} 77 | > 78 | <> 79 | 80 | 81 | {children} 82 | 83 | 84 | ) 85 | } 86 | 87 | export { ThemeProvider, css, keyframes } 88 | 89 | export default styled 90 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "allowSyntheticDefaultImports": true, 5 | "moduleResolution": "node", 6 | "target": "esnext", 7 | "strict": true, 8 | "sourceMap": true, 9 | "lib": ["esnext", "dom"], 10 | "jsx": "react", 11 | "baseUrl": "./src/", 12 | "paths": { 13 | "~*": ["./*"] 14 | } 15 | } 16 | } 17 | --------------------------------------------------------------------------------