diff --git a/src/css/options.scss b/src/css/options.scss index 8b42215..d5eccfa 100644 --- a/src/css/options.scss +++ b/src/css/options.scss @@ -38,5 +38,16 @@ .text-danger { color: $red; } + + .moco-bx-override-hosts { + &-container { + padding: 0 20px; + text-align: center; + } + &-btn { + cursor: pointer; + text-decoration: underline; + } + } } } diff --git a/src/js/components/Options.js b/src/js/components/Options.js index c54af85..91804da 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 remoteServices from "../remoteServices" +import { pipe, prop, map, sortedUniqBy, filter } from "lodash/fp" @observer class Options extends Component { @observable subdomain = "" @observable apiKey = "" + @observable hostOverrides = {} @observable errorMessage = null @observable isSuccess = false + @observable servicesHostOverrideList = [] + @observable showHostOverrideOptions = false componentDidMount() { - getSettings(false).then(({ subdomain, apiKey }) => { - this.subdomain = subdomain || "" - this.apiKey = apiKey || "" + this.servicesHostOverrideList = pipe( + filter(prop("allowHostOverride")), + map((remoteService) => ({ + name: remoteService.name, + host: remoteService.host, + })), + sortedUniqBy("name"), + )(remoteServices) + + getSettings(false).then((storeData) => { + this.subdomain = storeData.subdomain || "" + this.apiKey = storeData.apiKey || "" + this.hostOverrides = storeData.hostOverrides || {} }) } - onChange = event => { + onChange = (event) => { this[event.target.name] = event.target.value.trim() } - handleSubmit = _event => { + onChangeHostOverrides = (event) => { + // ensure to remove path (and trailing slash) from URL, as this can cause problems otherwise + this.hostOverrides[event.target.name] = this.removePathFromUrl(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: this.hostOverrides, }).then(() => { const { version } = chrome.runtime.getManifest() const apiClient = new ApiClient({ @@ -45,13 +71,17 @@ class Options extends Component { this.isSuccess = true this.closeWindow() }) - .catch(error => { + .catch((error) => { this.errorMessage = error.response?.data?.message || "Anmeldung fehlgeschlagen" }) }) } - handleInputKeyDown = event => { + removePathFromUrl = (url) => { + return url.replace(/(\.[a-z]+)\/.*$/, "$1") + } + + handleInputKeyDown = (event) => { if (event.key === "Enter") { this.handleSubmit() } @@ -93,6 +123,27 @@ class Options extends Component { Den API-Schlüssel findest du in deinem Profil unter "Integrationen".

+
+ {!this.showHostOverrideOptions && ( +
+ Zeige Optionen zum Überschreiben
der Service Hosts
+
+ )} + {this.showHostOverrideOptions && + this.servicesHostOverrideList.map((remoteService) => ( +
+ + +
+ ))} +
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/remoteServices.js b/src/js/remoteServices.js index 59ea145..ebd9362 100644 --- a/src/js/remoteServices.js +++ b/src/js/remoteServices.js @@ -9,9 +9,10 @@ const projectIdentifierBySelector = selector => document => 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 => document.querySelector(".ItemRow--highlighted textarea")?.textContent?.trim() || @@ -19,32 +20,38 @@ export default { 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(), projectId: projectIdentifierBySelector(".js-issue-title"), + allowHostOverride: true, }, "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: true, }, 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,11 +66,13 @@ 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"], + host: "https://www.meistertask.com", + urlPatterns: ["/app/task/:id/:slug"], description: document => { const json = document.getElementById("mt-toggl-data")?.dataset?.togglJson || "{}" const data = JSON.parse(json) @@ -75,30 +84,36 @@ export default { 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 => projectIdentifierBySelector(".js-title-helper")(document) || projectIdentifierBySelector(".js-board-editing-target")(document), + allowHostOverride: false, }, youtrack: { name: "youtrack", - urlPatterns: ["https\\://:org.myjetbrains.com/youtrack/issue/:id"], + 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", ], @@ -107,39 +122,46 @@ export default { }, 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(/*)"], + 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..92d81cf 100644 --- a/src/js/utils/browser.js +++ b/src/js/utils/browser.js @@ -1,11 +1,12 @@ import { head } from "lodash/fp" + export const isChrome = () => typeof browser === "undefined" && chrome export const isFirefox = () => typeof browser !== "undefined" && chrome const DEFAULT_SUBDOMAIN = "unset" 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 => { 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..f4fb98a 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, convert } from "lodash/fp" import { asArray } from "./index" import queryString from "query-string" @@ -39,15 +39,36 @@ const createEvaluator = args => fnOrValue => { return fnOrValue } +const prepareHostForRegExp = (host) => { + if (isUndefined(host)) { + return + } + + 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 { + console.error("Invalid type for pattern %v, no host replacement performed", pattern) + return pattern + } +} + const parseServices = compose( map(([key, config]) => ({ ...config, key, patterns: config.urlPatterns.map(pattern => { if (Array.isArray(pattern)) { - return new UrlPattern(...pattern) + return new UrlPattern( + ...pattern.map((p, index) => (index === 0 ? replaceHostInPattern(config.host, p) : p)), + ) } - return new UrlPattern(pattern) + return new UrlPattern(replaceHostInPattern(config.host, pattern)) }), })), toPairs, @@ -72,8 +93,28 @@ export const createEnhancer = document => service => { } } -export const createMatcher = remoteServices => { - const services = parseServices(remoteServices) +const applyHostOverrides = (remoteServices, hostOverrides) => { + let appliedRemoteServices = Object.assign(remoteServices) + if (isUndefined(hostOverrides)) { + console.error("No overrides found.") + return remoteServices + } + + Object.keys(remoteServices).forEach((key) => { + const remoteService = remoteServices[key] + appliedRemoteServices[key] = { + ...remoteService, + key, + host: (hostOverrides && hostOverrides[remoteService.name]) || remoteService.host, + } + }) + + return appliedRemoteServices +} + +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}` @@ -99,8 +140,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, 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) + }) + }) + }) })