diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..8351c19 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 78725a0..d8c71ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.0] - 2020-06-15 + +### Added + +- Allow to override hosts for Jira, Youtrack and Gitlab in options (implemented by yay-digital.de) + +## [1.4.0] - 2020-04-27 + +### Added + +- Add support for Gitlab merge requests and issues + ## [1.3.4] - 2020-01-09 ### Added diff --git a/README.md b/README.md index 20ae5d6..e560a5c 100644 --- a/README.md +++ b/README.md @@ -2,33 +2,34 @@ ## Development -* run `yarn` -* run `yarn start:chrome` or `yarn start:firefox` (`yarn start` is an alias for `yarn start:chrome`) -* load extension into browser: - * Chrome: visit `chrome://extensions` and load unpacked extension from `build/chrome` - * Firefox: visit `about:debugging` and load temporary Add-on from `build/firefox` -* reload browser extension after change +- run `yarn` +- run `yarn start:chrome` or `yarn start:firefox` (`yarn start` is an alias for `yarn start:chrome`) +- load extension into browser: + - Chrome: visit `chrome://extensions` and load unpacked extension from `build/chrome` + - Firefox: visit `about:debugging` and load temporary Add-on from `build/firefox` +- reload browser extension after change ## Production Build -* bump version in `package.json` -* run `yarn build` -* The Chrome and Firefox extensions are available as ZIP-files in `build/chrome` and `build/firefox` respectively +- bump version in `package.json` +- Update `CHANGELOG.md` +- run `yarn build` +- The Chrome and Firefox extensions are available as ZIP-files in `build/chrome` and `build/firefox` respectively ## Install Local Builds ### Chrome -1. `yarn build:chrome` -1. Visit `chrome://extensions` -2. Enable `Developer mode` -3. `Load unpacked` and select the `build/chrome` folder. +- `yarn build:chrome` +- Visit `chrome://extensions` +- Enable `Developer mode` +- `Load unpacked` and select the `build/chrome` folder. ### Firefox -1. `yarn build:firefox` -1. Visit `about:debugging` -2. Click on `Load temporary Add-on` and select the ZIP-file in `build/firefox` +- `yarn build:firefox` +- Visit `about:debugging` +- Click on `Load temporary Add-on` and select the ZIP-file in `build/firefox` Only signed extensions can be permantly installed in Firefox (unless you are using Firefox Developer Edition). To sign the build, proceed as described in [Getting started with web-ext](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Getting_started_with_web-ext). @@ -36,7 +37,7 @@ You can keep the extension settings between builds by providing a stable `APPLIC `APPLICATION_ID=my-custom-moco-extension@mycompany.com yarn build:firefox` -## Remote Service Configuration +## Remote Service Configuration Remote services are configured in `src/js/remoteServices.js`. @@ -46,9 +47,10 @@ A remote service is configured as follows: { service_key: { name: "service_name", + host: "https://:subdomain.example.com", urlPatterns: [ - "https:\\://:subdomain.example.com/card/:id", - [/^https:\/\/(\w+).example.com\/card\/(\d+), ["subdomain", "id"]], + ":host:/card/:id", + [/^:host:\/card\/(\d+), ["subdomain", "id"]], ], queryParams: { projectId: "currentList" @@ -59,24 +61,25 @@ A remote service is configured as follows: ?.textContent ?.trim() return `#${id} ${service.key} ${title || ""}` - }, + }, projectId: (document, service, { subdomain, id, projectId }) => { return projectId }, - position: { left: "50%", transform: "translate(-50%)" } + position: { left: "50%", transform: "translate(-50%)" }, + allowHostOverride: false, } } ``` -| Parameter | Description | -|--------------|:-------------| -| service_key | `string` — Unique identifier for the service | -| service_name | `string` — Must be one of the registered services `trello`, `jira`, `asana`, `wunderlist`, `github` or `youtrack` | -| urlPatterns | `string` \| `RegEx` — A valid URL pattern or regular expression, as described in the [url-pattern](https://www.npmjs.com/package/url-pattern) package. | -| queryParams | `Object` — The object value is the name of the query parameter and the key the property it will available on, e.g. the value of the query parameter `currentList` will be available under `projectId`. Matches in `urlPatterns` have precedence over matches in `queryParams`. | -| description | `undefined` \| `string` \| `function` — The default description of the service. If it is a function, it will receive `window.document`, the current `service` and an object with the URL `matches` as arguments, and the return value set as the default description. | +| Parameter | Description | +| ------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| service_key | `string` — Unique identifier for the service | +| service_name | `string` — Must be one of the registered services `trello`, `jira`, `asana`, `wunderlist`, `github` or `youtrack` | +| urlPatterns | `string` \| `RegEx` — A valid URL pattern or regular expression, as described in the [url-pattern](https://www.npmjs.com/package/url-pattern) package. `:host:` will be replaced with the configured host before applying the pattern (can be configured in the settings if `allowHostOverride` is true. | +| queryParams | `Object` — The object value is the name of the query parameter and the key the property it will available on, e.g. the value of the query parameter `currentList` will be available under `projectId`. Matches in `urlPatterns` have precedence over matches in `queryParams`. | +| description | `undefined` \| `string` \| `function` — The default description of the service. If it is a function, it will receive `window.document`, the current `service` and an object with the URL `matches` as arguments, and the return value set as the default description. | | projectId | `undefined` \| `string` \| `function` — The pre-selected project of the service matching the MOCO project identifier. If it is a function, it will receive `window.document`, the current `service` and an object with the URL `matches` as arguments, and must return the MOCO project identifier or `undefined`. | -| position | `Object` — CSS properties as object styles for position the bubble. Defaults to `{ right: calc(4rem + 5px)` | +| position | `Object` — CSS properties as object styles for position the bubble. Defaults to `{ right: calc(4rem + 5px)` | ## Adding a Custom Service diff --git a/package.json b/package.json index d5a4b08..09a3984 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "moco-browser-extensions", "description": "Browser plugin for MOCO", - "version": "1.4.0", + "version": "1.5.0", "license": "MIT", "scripts": { "start": "yarn start:chrome", diff --git a/src/css/_button.scss b/src/css/_button.scss index c96220b..65d2956 100644 --- a/src/css/_button.scss +++ b/src/css/_button.scss @@ -48,3 +48,17 @@ button.moco-bx-btn { margin-left: 0.5rem; } } + +.moco-bx-btn__secondary { + color: $blue; + border: none; + background: none; + text-decoration: none; + + &:hover { + cursor: pointer; + color: $blue; + border: none; + background-color: transparent; + } +} diff --git a/src/css/_form.scss b/src/css/_form.scss index a55c3fb..9bb247c 100644 --- a/src/css/_form.scss +++ b/src/css/_form.scss @@ -63,8 +63,13 @@ input { text-align: center; background-color: #eeeeee; border: 1px solid #cccccc; - border-left: none; line-height: 18px; + &--right { + border-left: none; + } + &--left { + border-right: none; + } } } } diff --git a/src/css/options.scss b/src/css/options.scss index 8b42215..098a5d3 100644 --- a/src/css/options.scss +++ b/src/css/options.scss @@ -11,6 +11,11 @@ .moco-bx-options { padding: 0rem 2rem 2rem; + a { + color: $blue; + text-decoration: none; + } + p { margin: 0.5rem 0; } @@ -20,6 +25,11 @@ margin: 1rem 0 2rem; } + h3 { + font-size: 1.1rem; + margin: 1rem 0 1rem; + } + label { font-weight: normal; margin-bottom: 5px; @@ -38,5 +48,15 @@ .text-danger { color: $red; } + + &__host-overrides { + margin-bottom: 1.5rem; + text-align: center; + font-weight: normal; + } + + small { + font-size: 0.8rem; + } } } diff --git a/src/js/components/App.js b/src/js/components/App.js index 520e206..3ff6013 100644 --- a/src/js/components/App.js +++ b/src/js/components/App.js @@ -119,7 +119,7 @@ class App extends Component { chrome.runtime.onMessage.removeListener(this.handleSetFormErrors) } - handleChange = event => { + handleChange = (event) => { const { projects } = this.props const { target: { name, value }, @@ -133,11 +133,11 @@ class App extends Component { } } - handleSelectDate = date => { + handleSelectDate = (date) => { this.changeset.date = formatDate(date) } - handleStopTimer = timedActivity => { + handleStopTimer = (timedActivity) => { const { service } = this.props chrome.runtime.sendMessage({ @@ -146,7 +146,7 @@ class App extends Component { }) } - handleSubmit = event => { + handleSubmit = (event) => { event.preventDefault() const { service } = this.props @@ -159,7 +159,7 @@ class App extends Component { }) } - handleKeyDown = event => { + handleKeyDown = (event) => { if (event.keyCode === 27) { event.stopPropagation() chrome.runtime.sendMessage({ type: "closePopup" }) @@ -204,7 +204,7 @@ class App extends Component { return ( - {props => ( + {(props) => (
diff --git a/src/js/components/Options.js b/src/js/components/Options.js index c54af85..cf785e3 100644 --- a/src/js/components/Options.js +++ b/src/js/components/Options.js @@ -3,32 +3,58 @@ import { observable } from "mobx" import { observer } from "mobx-react" import { isChrome, getSettings, setStorage } from "utils/browser" import ApiClient from "api/Client" +import { pipe, toPairs, fromPairs, map } from "lodash/fp" + +function upperCaseFirstLetter(input) { + return input[0].toUpperCase() + input.slice(1) +} + +function removePathFromUrl(url) { + return url.replace(/(\.[a-z]+)\/.*$/, "$1") +} @observer class Options extends Component { @observable subdomain = "" @observable apiKey = "" + @observable hostOverrides = {} @observable errorMessage = null @observable isSuccess = false + @observable showHostOverrideOptions = false componentDidMount() { - getSettings(false).then(({ subdomain, apiKey }) => { - this.subdomain = subdomain || "" - this.apiKey = apiKey || "" + getSettings(false).then((settings) => { + this.subdomain = settings.subdomain || "" + this.apiKey = settings.apiKey || "" + this.hostOverrides = settings.hostOverrides }) } - onChange = event => { + handleChange = (event) => { this[event.target.name] = event.target.value.trim() } - handleSubmit = _event => { + handleChangeHostOverrides = (event) => { + this.hostOverrides[event.target.name] = event.target.value.trim() + } + + toggleHostOverrideOptions = () => { + this.showHostOverrideOptions = !this.showHostOverrideOptions + } + + handleSubmit = (_event) => { this.isSuccess = false this.errorMessage = null + setStorage({ subdomain: this.subdomain, apiKey: this.apiKey, settingTimeTrackingHHMM: false, + hostOverrides: pipe( + toPairs, + map(([key, url]) => [key, removePathFromUrl(url)]), + fromPairs, + )(this.hostOverrides), }).then(() => { const { version } = chrome.runtime.getManifest() const apiClient = new ApiClient({ @@ -45,13 +71,13 @@ class Options extends Component { this.isSuccess = true this.closeWindow() }) - .catch(error => { + .catch((error) => { this.errorMessage = error.response?.data?.message || "Anmeldung fehlgeschlagen" }) }) } - handleInputKeyDown = event => { + handleInputKeyDown = (event) => { if (event.key === "Enter") { this.handleSubmit() } @@ -75,9 +101,9 @@ class Options extends Component { name="subdomain" value={this.subdomain} onKeyDown={this.handleInputKeyDown} - onChange={this.onChange} + onChange={this.handleChange} /> - .mocoapp.com + .mocoapp.com
@@ -87,12 +113,59 @@ class Options extends Component { name="apiKey" value={this.apiKey} onKeyDown={this.handleInputKeyDown} - onChange={this.onChange} + onChange={this.handleChange} />

Den API-Schlüssel findest du in deinem Profil unter "Integrationen".

+ {!this.showHostOverrideOptions && ( +
+ + Service-URLs überschreiben? + +
+ )} + {this.showHostOverrideOptions && ( +
+

Service-URLs

+ + Doppelpunkt für Platzhalter verwenden, z.B.{" "} + :org. Siehe{" "} + + Online-Doku + + . + +
+ {pipe( + Object.entries, + Array.from, + )(this.hostOverrides).map(([name, host]) => ( +
+
+ + {upperCaseFirstLetter(name)} + + +
+
+ ))} +
+ )} diff --git a/src/js/content.js b/src/js/content.js index 441af39..bb6c0c0 100644 --- a/src/js/content.js +++ b/src/js/content.js @@ -8,9 +8,14 @@ import { createServiceFinder } from "utils/urlMatcher" import remoteServices from "./remoteServices" import { ContentMessenger } from "utils/messaging" import "../css/content.scss" +import { getSettings } from "./utils/browser" const popupRef = createRef() -const findService = createServiceFinder(remoteServices)(document) + +let findService +getSettings().then((settings) => { + findService = createServiceFinder(remoteServices, settings.hostOverrides)(document) +}) chrome.runtime.onConnect.addListener(function (port) { const messenger = new ContentMessenger(port) diff --git a/src/js/options.js b/src/js/options.js index 20f7f93..bdd36bf 100644 --- a/src/js/options.js +++ b/src/js/options.js @@ -1,3 +1,4 @@ +import "mobx-react-lite/batchingForReactDom" import React from "react" import ReactDOM from "react-dom" import Options from "./components/Options" diff --git a/src/js/popup.js b/src/js/popup.js index d308e9c..77cec7f 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -1,3 +1,4 @@ +import "mobx-react-lite/batchingForReactDom" import React from "react" import ReactDOM from "react-dom" import App from "./components/App" diff --git a/src/js/remoteServices.js b/src/js/remoteServices.js index 59ea145..b87a9a9 100644 --- a/src/js/remoteServices.js +++ b/src/js/remoteServices.js @@ -1,50 +1,54 @@ const projectRegex = /\[([\w-]+)\]/ -const projectIdentifierBySelector = selector => document => - document - .querySelector(selector) - ?.textContent?.trim() - ?.match(projectRegex)?.[1] +const projectIdentifierBySelector = (selector) => (document) => + document.querySelector(selector)?.textContent?.trim()?.match(projectRegex)?.[1] export default { asana: { name: "asana", + host: "https://app.asana.com", urlPatterns: [ - [/^https:\/\/app.asana.com\/0\/([^/]+)\/(\d+)/, ["domainUserId", "id"]], - [/^https:\/\/app.asana.com\/0\/search\/([^/]+)\/(\d+)/, ["domainUserId", "id"]], + [/^:host:\/0\/([^/]+)\/(\d+)/, ["domainUserId", "id"]], + [/^:host:\/0\/search\/([^/]+)\/(\d+)/, ["domainUserId", "id"]], ], - description: document => + description: (document) => document.querySelector(".ItemRow--highlighted textarea")?.textContent?.trim() || document.querySelector(".ItemRow--focused textarea")?.textContent?.trim() || document.querySelector(".SingleTaskPane textarea")?.textContent?.trim() || document.querySelector(".SingleTaskTitleInput-taskName textarea")?.textContent?.trim(), projectId: projectIdentifierBySelector(".TopbarPageHeaderStructure-titleRow h1"), + allowHostOverride: false, }, "github-pr": { name: "github", - urlPatterns: ["https\\://github.com/:org/:repo/pull/:id(/:tab)"], + host: "https://github.com", + urlPatterns: [":host:/:org/:repo/pull/:id(/:tab)"], id: (document, service, { org, repo, id }) => [service.key, org, repo, id].join("."), - description: document => document.querySelector(".js-issue-title")?.textContent?.trim(), + description: (document) => document.querySelector(".js-issue-title")?.textContent?.trim(), projectId: projectIdentifierBySelector(".js-issue-title"), + allowHostOverride: false, }, "github-issue": { name: "github", - urlPatterns: ["https\\://github.com/:org/:repo/issues/:id"], + host: "https://github.com", + urlPatterns: [":host:/:org/:repo/issues/:id"], id: (document, service, { org, repo, id }) => [service.key, org, repo, id].join("."), description: (document, service, { org, repo, id }) => document.querySelector(".js-issue-title")?.textContent?.trim(), projectId: projectIdentifierBySelector(".js-issue-title"), + allowHostOverride: false, }, jira: { name: "jira", + host: "https://:org.atlassian.net", urlPatterns: [ - "https\\://:org.atlassian.net/secure/RapidBoard.jspa", - "https\\://:org.atlassian.net/browse/:id", - "https\\://:org.atlassian.net/jira/software/projects/:projectId/boards/:board", - "https\\://:org.atlassian.net/jira/software/projects/:projectId/boards/:board/backlog", + ":host:/secure/RapidBoard.jspa", + ":host:/browse/:id", + ":host:/jira/software/projects/:projectId/boards/:board", + ":host:/jira/software/projects/:projectId/boards/:board/backlog", ], queryParams: { id: "selectedIssue", @@ -59,87 +63,102 @@ export default { document.querySelector(".ghx-selected .ghx-summary")?.textContent?.trim() return `#${id} ${title || ""}` }, + allowHostOverride: true, }, meistertask: { name: "meistertask", - urlPatterns: ["https\\://www.meistertask.com/app/task/:id/:slug"], - description: document => { + host: "https://www.meistertask.com", + urlPatterns: [":host:/app/task/:id/:slug"], + description: (document) => { const json = document.getElementById("mt-toggl-data")?.dataset?.togglJson || "{}" const data = JSON.parse(json) return data.taskName }, - projectId: document => { + projectId: (document) => { const json = document.getElementById("mt-toggl-data")?.dataset?.togglJson || "{}" const data = JSON.parse(json) const match = data.taskName?.match(projectRegex) || data.projectName?.match(projectRegex) return match && match[1] }, + allowHostOverride: false, }, trello: { name: "trello", - urlPatterns: ["https\\://trello.com/c/:id/:title"], + host: "https://trello.com", + urlPatterns: [":host:/c/:id/:title"], description: (document, service, { title }) => document.querySelector(".js-title-helper")?.textContent?.trim() || title, - projectId: document => + projectId: (document) => projectIdentifierBySelector(".js-title-helper")(document) || projectIdentifierBySelector(".js-board-editing-target")(document), + allowHostOverride: false, }, youtrack: { name: "youtrack", - urlPatterns: ["https\\://:org.myjetbrains.com/youtrack/issue/:id"], - description: document => document.querySelector("yt-issue-body h1")?.textContent?.trim(), + host: "https://:org.myjetbrains.com", + urlPatterns: [":host:/youtrack/issue/:id"], + description: (document) => document.querySelector("yt-issue-body h1")?.textContent?.trim(), projectId: projectIdentifierBySelector("yt-issue-body h1"), + allowHostOverride: true, }, wrike: { name: "wrike", + host: "https://www.wrike.com", urlPatterns: [ - "https\\://www.wrike.com/workspace.htm#path=mywork", - "https\\://www.wrike.com/workspace.htm#path=folder", + ":host:/workspace.htm#path=mywork", + ":host:/workspace.htm#path=folder", "https\\://app-eu.wrike.com/workspace.htm#path=mywork", "https\\://app-eu.wrike.com/workspace.htm#path=folder", ], queryParams: { id: ["t", "ot"], }, - description: document => document.querySelector(".title-field-ghost")?.textContent?.trim(), + description: (document) => document.querySelector(".title-field-ghost")?.textContent?.trim(), projectId: projectIdentifierBySelector(".header-title__main"), + allowHostOverride: false, }, wunderlist: { name: "wunderlist", - urlPatterns: ["https\\://www.wunderlist.com/(webapp)#/tasks/:id(/*)"], - description: document => + host: "https://www.wunderlist.com", + urlPatterns: [":host:/(webapp)#/tasks/:id(/*)"], + description: (document) => document .querySelector(".taskItem.selected .taskItem-titleWrapper-title") ?.textContent?.trim(), projectId: projectIdentifierBySelector(".taskItem.selected .taskItem-titleWrapper-title"), + allowHostOverride: false, }, "gitlab-mr": { name: "gitlab", + host: "https://gitlab.com", urlPatterns: [ - "https\\://gitlab.com/:org/:group/:projectId/-/merge_requests/:id", - "https\\://gitlab.com/:org/:projectId/-/merge_requests/:id", + ":host:/:org/:group/:projectId/-/merge_requests/:id", + ":host:/:org/:projectId/-/merge_requests/:id", ], description: (document, service, { id }) => { const title = document.querySelector("h2.title")?.textContent?.trim() return `#${id} ${title || ""}`.trim() }, + allowHostOverride: true, }, "gitlab-issues": { name: "gitlab", + host: "https://gitlab.com", urlPatterns: [ - "https\\://gitlab.com/:org/:group/:projectId/-/issues/:id", - "https\\://gitlab.com/:org/:projectId/-/issues/:id", + ":host:/:org/:group/:projectId/-/issues/:id", + ":host:/:org/:projectId/-/issues/:id", ], description: (document, service, { id }) => { const title = document.querySelector("h2.title")?.textContent?.trim() return `#${id} ${title || ""}`.trim() }, + allowHostOverride: true, }, } diff --git a/src/js/utils/browser.js b/src/js/utils/browser.js index 985fbea..f8ab572 100644 --- a/src/js/utils/browser.js +++ b/src/js/utils/browser.js @@ -1,34 +1,53 @@ -import { head } from "lodash/fp" -export const isChrome = () => typeof browser === "undefined" && chrome -export const isFirefox = () => typeof browser !== "undefined" && chrome +import { head, pick, reduce, filter, prop, pipe } from "lodash/fp" +import remoteServices from "../remoteServices" const DEFAULT_SUBDOMAIN = "unset" +export const isChrome = () => typeof browser === "undefined" && chrome +export const isFirefox = () => typeof browser !== "undefined" && chrome + +export const defaultHostOverrides = pipe( + filter(prop("allowHostOverride")), + reduce((acc, remoteService) => { + acc[remoteService.name] = remoteService.host + return acc + }, {}), +)(remoteServices) + +// We pick only the keys defined in `defaultHostOverrides`, so that +// deleted host overrides get cleared from the settings +const getHostOverrides = (settings) => ({ + ...defaultHostOverrides, + ...pick(Object.keys(defaultHostOverrides), settings.hostOverrides || {}), +}) + export const getSettings = (withDefaultSubdomain = true) => { - const keys = ["subdomain", "apiKey", "settingTimeTrackingHHMM"] + const keys = ["subdomain", "apiKey", "settingTimeTrackingHHMM", "hostOverrides"] const { version } = chrome.runtime.getManifest() if (isChrome()) { - return new Promise(resolve => { - chrome.storage.sync.get(keys, data => { + return new Promise((resolve) => { + chrome.storage.sync.get(keys, (settings) => { if (withDefaultSubdomain) { - data.subdomain = data.subdomain || DEFAULT_SUBDOMAIN + settings.subdomain = settings.subdomain || DEFAULT_SUBDOMAIN } - resolve({ ...data, version }) + settings.hostOverrides = getHostOverrides(settings) + resolve({ ...settings, version }) }) }) } else { - return browser.storage.sync.get(keys).then(data => { + return browser.storage.sync.get(keys).then((settings) => { if (withDefaultSubdomain) { - data.subdomain = data.subdomain || DEFAULT_SUBDOMAIN + settings.subdomain = settings.subdomain || DEFAULT_SUBDOMAIN } - return { ...data, version } + settings.hostOverrides = getHostOverrides(settings) + return { ...settings, version } }) } } -export const setStorage = items => { +export const setStorage = (items) => { if (isChrome()) { - return new Promise(resolve => { + return new Promise((resolve) => { chrome.storage.sync.set(items, resolve) }) } else { @@ -36,9 +55,9 @@ export const setStorage = items => { } } -export const queryTabs = queryInfo => { +export const queryTabs = (queryInfo) => { if (isChrome()) { - return new Promise(resolve => chrome.tabs.query(queryInfo, resolve)) + return new Promise((resolve) => chrome.tabs.query(queryInfo, resolve)) } else { return browser.tabs.query(queryInfo) } @@ -48,4 +67,4 @@ export const getCurrentTab = () => { return queryTabs({ currentWindow: true, active: true }).then(head) } -export const isBrowserTab = tab => /^(?:chrome|about):/.test(tab.url) +export const isBrowserTab = (tab) => /^(?:chrome|about):/.test(tab.url) diff --git a/src/js/utils/index.js b/src/js/utils/index.js index c207d9d..796e2ea 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -17,41 +17,30 @@ import { import { startOfWeek, endOfWeek } from "date-fns" import { format } from "date-fns" -const nilToArray = input => input || [] +const nilToArray = (input) => input || [] export const ERROR_UNAUTHORIZED = "unauthorized" export const ERROR_UPGRADE_REQUIRED = "upgrade-required" export const ERROR_UNKNOWN = "unknown" export const noop = () => null -export const asArray = input => (Array.isArray(input) ? input : [input]) +export const asArray = (input) => (Array.isArray(input) ? input : [input]) -export const findProjectBy = prop => val => projects => { +export const findProjectBy = (prop) => (val) => (projects) => { if (!val) { return undefined } - return compose( - find(pathEq(prop, val)), - flatMap(get("options")), - )(projects) + return compose(find(pathEq(prop, val)), flatMap(get("options")))(projects) } export const findProjectByIdentifier = findProjectBy("identifier") export const findProjectByValue = findProjectBy("value") -export const findTask = id => - compose( - find(pathEq("value", Number(id))), - get("tasks"), - ) +export const findTask = (id) => compose(find(pathEq("value", Number(id))), get("tasks")) -export const defaultTask = tasks => - compose( - defaultTo(head(tasks)), - find(pathEq("isDefault", true)), - nilToArray, - )(tasks) +export const defaultTask = (tasks) => + compose(defaultTo(head(tasks)), find(pathEq("isDefault", true)), nilToArray)(tasks) function taskOptions(tasks) { return tasks.map(({ id, name, billable, default: isDefault }) => ({ @@ -63,7 +52,7 @@ function taskOptions(tasks) { } export function projectOptions(projects) { - return projects.map(project => ({ + return projects.map((project) => ({ value: project.id, label: project.intern ? `(${project.name})` : project.name, identifier: project.identifier, @@ -82,17 +71,9 @@ export const groupedProjectOptions = compose( nilToArray, ) -export const serializeProps = attrs => - compose( - mapValues(JSON.stringify), - pick(attrs), - ) +export const serializeProps = (attrs) => compose(mapValues(JSON.stringify), pick(attrs)) -export const parseProps = attrs => - compose( - mapValues(JSON.parse), - pick(attrs), - ) +export const parseProps = (attrs) => compose(mapValues(JSON.parse), pick(attrs)) export const trace = curry((tag, value) => { // eslint-disable-next-line no-console @@ -101,13 +82,13 @@ export const trace = curry((tag, value) => { }) export const weekStartsOn = 1 -export const formatDate = date => format(date, "yyyy-MM-dd") +export const formatDate = (date) => format(date, "yyyy-MM-dd") export const getStartOfWeek = () => startOfWeek(new Date(), { weekStartsOn }) export const getEndOfWeek = () => endOfWeek(new Date(), { weekStartsOn }) export const extensionSettingsUrl = () => `chrome://extensions/?id=${chrome.runtime.id}` -export const extractAndSetTag = changeset => { +export const extractAndSetTag = (changeset) => { let { description } = changeset const match = description.match(/^#(\S+)/) if (!match) { diff --git a/src/js/utils/messageHandlers.js b/src/js/utils/messageHandlers.js index 705de4b..d006a83 100644 --- a/src/js/utils/messageHandlers.js +++ b/src/js/utils/messageHandlers.js @@ -12,7 +12,15 @@ import { createMatcher } from "utils/urlMatcher" import remoteServices from "remoteServices" import { queryTabs, isBrowserTab, getSettings, setStorage } from "utils/browser" -const matcher = createMatcher(remoteServices) +let matcher + +const initMatcher = (settings) => { + matcher = createMatcher(remoteServices, settings.hostOverrides) +} + +getSettings().then((settings) => { + initMatcher(settings) +}) export function tabUpdated(tab, { messenger, settings }) { messenger.connectTab(tab) @@ -54,6 +62,8 @@ export function tabUpdated(tab, { messenger, settings }) { } export function settingsChanged(settings, { messenger }) { + initMatcher(settings) + queryTabs({ currentWindow: true }) .then(reject(isBrowserTab)) .then( diff --git a/src/js/utils/urlMatcher.js b/src/js/utils/urlMatcher.js index 4767fa2..9c522f0 100644 --- a/src/js/utils/urlMatcher.js +++ b/src/js/utils/urlMatcher.js @@ -1,5 +1,5 @@ import UrlPattern from "url-pattern" -import { isFunction, isUndefined, compose, toPairs, map, pipe, isNil } from "lodash/fp" +import { isFunction, isUndefined, compose, toPairs, map, pipe, isNil, reduce } from "lodash/fp" import { asArray } from "./index" import queryString from "query-string" @@ -19,7 +19,7 @@ function parseUrl(url) { function extractQueryParams(queryParams, query) { return toPairs(queryParams).reduce((acc, [key, params]) => { - const param = asArray(params).find(param => !isNil(query[param])) + const param = asArray(params).find((param) => !isNil(query[param])) if (param) { acc[key] = query[param] } @@ -27,7 +27,7 @@ function extractQueryParams(queryParams, query) { }, {}) } -const createEvaluator = args => fnOrValue => { +const createEvaluator = (args) => (fnOrValue) => { if (isUndefined(fnOrValue)) { return } @@ -39,21 +39,35 @@ const createEvaluator = args => fnOrValue => { return fnOrValue } +const prepareHostForRegExp = (host) => { + return host.replace(":", "\\:") +} + +const replaceHostInPattern = (host, pattern) => { + if (typeof pattern === "string") { + return pattern.replace(":host:", prepareHostForRegExp(host)) + } else if (pattern instanceof RegExp) { + return new RegExp(pattern.source.replace(":host:", prepareHostForRegExp(host))) + } else { + return pattern + } +} + const parseServices = compose( map(([key, config]) => ({ ...config, key, - patterns: config.urlPatterns.map(pattern => { + patterns: config.urlPatterns.map((pattern) => { if (Array.isArray(pattern)) { - return new UrlPattern(...pattern) + return new UrlPattern(...pattern.map((p) => replaceHostInPattern(config.host, p))) } - return new UrlPattern(pattern) + return new UrlPattern(replaceHostInPattern(config.host, pattern)) }), })), toPairs, ) -export const createEnhancer = document => service => { +export const createEnhancer = (document) => (service) => { if (!service) { return } @@ -72,18 +86,34 @@ export const createEnhancer = document => service => { } } -export const createMatcher = remoteServices => { - const services = parseServices(remoteServices) - return tabUrl => { +const applyHostOverrides = (remoteServices, hostOverrides) => + pipe( + toPairs, + reduce((acc, [key, remoteService]) => { + acc[key] = { + ...remoteService, + key, + host: hostOverrides[remoteService.name] || remoteService.host, + } + return acc + }, {}), + )(remoteServices) + +export const createMatcher = (remoteServices, hostOverrides) => { + const services = parseServices(applyHostOverrides(remoteServices, hostOverrides)) + + return (tabUrl) => { const { origin, pathname, hash, query } = parseUrl(tabUrl) const url = `${origin}${pathname}${hash}` - const service = services.find(service => service.patterns.some(pattern => pattern.match(url))) + const service = services.find((service) => { + return service.patterns.some((pattern) => pattern.match(url)) + }) if (!service) { return } - const pattern = service.patterns.find(pattern => pattern.match(url)) + const pattern = service.patterns.find((pattern) => pattern.match(url)) let match = pattern.match(url) if (service.queryParams) { const extractedQueryParams = extractQueryParams(service.queryParams, query) @@ -99,11 +129,8 @@ export const createMatcher = remoteServices => { } } -export const createServiceFinder = remoteServices => document => { - const matcher = createMatcher(remoteServices) +export const createServiceFinder = (remoteServices, hostOverrides) => (document) => { + const matcher = createMatcher(remoteServices, hostOverrides) const enhancer = createEnhancer(document) - return pipe( - matcher, - enhancer, - ) + return pipe(matcher, enhancer) } diff --git a/test/utils/urlMatcher.test.js b/test/utils/urlMatcher.test.js index 5b84686..42ae1dd 100644 --- a/test/utils/urlMatcher.test.js +++ b/test/utils/urlMatcher.test.js @@ -6,7 +6,7 @@ describe("utils", () => { let matcher beforeEach(() => { - matcher = createMatcher(remoteServices) + matcher = createMatcher(remoteServices, {}) }) describe("createMatcher", () => { @@ -162,4 +162,27 @@ describe("utils", () => { }) }) }) + + describe("urlMatcher with overrideHosts", () => { + let matcher + + beforeEach(() => { + matcher = createMatcher(remoteServices, { + github: "https://my-custom-github-url.com", + }) + }) + + describe("createMatcher", () => { + it("matches overridden host and path", () => { + const service = matcher("https://my-custom-github-url.com/hundertzehn/mocoapp/pull/123") + expect(service.key).toEqual("github-pr") + expect(service.name).toEqual("github") + }) + + it("doesn't match default host and path", () => { + const service = matcher("https://github.com/hundertzehn/mocoapp/pull/123") + expect(service).toBe(undefined) + }) + }) + }) }) diff --git a/webpack.base.config.js b/webpack.base.config.js index e99e890..1396b22 100644 --- a/webpack.base.config.js +++ b/webpack.base.config.js @@ -62,7 +62,7 @@ module.exports = (env) => { }, plugins: [ new CleanWebpackPlugin({ - cleanAfterEveryBuildPatterns: ["!manifest.json"], + cleanAfterEveryBuildPatterns: ["!manifest.json", "!*.html"], }), new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify(env.NODE_ENV),