feature/host-overrides (#161)
* configurable host overrides * base host overrides on name of service instead of key and hide the options by default * added unit tests * review changes * Refactor options * Refactor * Update Readme * Pump version and update Changelog Co-authored-by: Tobias Jacksteit <me@xtj7.de>
This commit is contained in:
parent
a13e30784c
commit
061a3d9a89
12
CHANGELOG.md
12
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
|
||||
|
61
README.md
61
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 <em>Firefox Developer Edition</em>). 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
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 (
|
||||
<Spring native from={{ opacity: 0 }} to={{ opacity: 1 }} config={config.stiff}>
|
||||
{props => (
|
||||
{(props) => (
|
||||
<animated.div className="moco-bx-app-container" style={props}>
|
||||
<Header subdomain={subdomain} />
|
||||
<Observer>
|
||||
|
@ -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}
|
||||
/>
|
||||
<span className="input-group-addon">.mocoapp.com</span>
|
||||
<span className="input-group-addon input-group-addon--right">.mocoapp.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@ -87,12 +113,59 @@ class Options extends Component {
|
||||
name="apiKey"
|
||||
value={this.apiKey}
|
||||
onKeyDown={this.handleInputKeyDown}
|
||||
onChange={this.onChange}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
<p className="text-muted">
|
||||
Den API-Schlüssel findest du in deinem Profil unter "Integrationen".
|
||||
</p>
|
||||
</div>
|
||||
{!this.showHostOverrideOptions && (
|
||||
<div className="moco-bx-options__host-overrides">
|
||||
<a href="#" className="moco-bx-btn__secondary" onClick={this.toggleHostOverrideOptions}>
|
||||
Service-URLs überschreiben?
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{this.showHostOverrideOptions && (
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<h3 style={{ marginBottom: 0 }}>Service-URLs</h3>
|
||||
<small>
|
||||
Doppelpunkt für Platzhalter verwenden, z.B.{" "}
|
||||
<span style={{ backgroundColor: "rgba(100, 100, 100, 0.1)" }}>:org</span>. Siehe{" "}
|
||||
<a
|
||||
href="https://github.com/hundertzehn/mocoapp-browser-extension#remote-service-configuration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Online-Doku
|
||||
</a>
|
||||
.
|
||||
</small>
|
||||
<br />
|
||||
{pipe(
|
||||
Object.entries,
|
||||
Array.from,
|
||||
)(this.hostOverrides).map(([name, host]) => (
|
||||
<div className="form-group" key={name} style={{ margin: "0.5rem 0" }}>
|
||||
<div className="input-group">
|
||||
<span
|
||||
className="input-group-addon input-group-addon--left"
|
||||
style={{ display: "inline-block", width: "70px", textAlign: "left" }}
|
||||
>
|
||||
{upperCaseFirstLetter(name)}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={name}
|
||||
value={host}
|
||||
onKeyDown={this.handleInputKeyDown}
|
||||
onChange={this.handleChangeHostOverrides}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button className="moco-bx-btn" onClick={this.handleSubmit}>
|
||||
OK
|
||||
</button>
|
||||
|
@ -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)
|
||||
|
@ -1,3 +1,4 @@
|
||||
import "mobx-react-lite/batchingForReactDom"
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
import Options from "./components/Options"
|
||||
|
@ -1,3 +1,4 @@
|
||||
import "mobx-react-lite/batchingForReactDom"
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
import App from "./components/App"
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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),
|
||||
|
Loading…
Reference in New Issue
Block a user