From a00ce8daee0e8020816275bd29e9a08541a888d5 Mon Sep 17 00:00:00 2001 From: Koen Mertens Date: Tue, 13 Sep 2022 12:42:34 +0200 Subject: [PATCH] Add ability to use Plausible(.io) for tracking Does not track unless explicitly configured by search.xml. Tracks initial page view on search and article page, as well as the submitted pattern and filters. (i.e. word and filters, but not pagination, sorting, grouping, etc.). --- src/frontend/package-lock.json | 56 +++++++-- src/frontend/package.json | 1 + src/frontend/src/article.ts | 14 ++- src/frontend/src/search.tsx | 16 ++- src/frontend/src/store/search/streams.ts | 5 +- .../corpuswebsite/utils/WebsiteConfig.java | 115 ++++++++++-------- .../resources/interface-default/search.xml | 6 + src/main/webapp/WEB-INF/templates/header.vm | 4 + 8 files changed, 152 insertions(+), 65 deletions(-) diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index bc46df86..3de40a2a 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -31,6 +31,7 @@ "urijs": "^1.19.7", "v-tooltip": "2.0.2", "vue": "^2.6.10", + "vue-plausible": "^1.3.2", "vue-rx": "^6.0.1", "vue-slider-component": "^2.8.1", "vuex": "^3.6.2", @@ -3269,14 +3270,20 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001306", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001306.tgz", - "integrity": "sha512-Wd1OuggRzg1rbnM5hv1wXs2VkxJH/AA+LuudlIqvZiCvivF+wJJe2mgBZC8gPMgI7D76PP5CTx8Luvaqc1V6OQ==", + "version": "1.0.30001399", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001399.tgz", + "integrity": "sha512-4vQ90tMKS+FkvuVWS5/QY1+d805ODxZiKFzsU8o/RsVJz49ZSRR8EjykLJbqhzdPgadbX6wB538wOzle3JniRA==", "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ] }, "node_modules/chalk": { "version": "2.4.2", @@ -6479,6 +6486,14 @@ "node": ">=8" } }, + "node_modules/plausible-tracker": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/plausible-tracker/-/plausible-tracker-0.3.8.tgz", + "integrity": "sha512-lmOWYQ7s9KOUJ1R+YTOR3HrjdbxIS2Z4de0P/Jx2dQPteznJl2eX3tXxKClpvbfyGP59B5bbhW8ftN59HbbFSg==", + "engines": { + "node": ">=10" + } + }, "node_modules/popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", @@ -8084,6 +8099,14 @@ } } }, + "node_modules/vue-plausible": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/vue-plausible/-/vue-plausible-1.3.2.tgz", + "integrity": "sha512-7hdLrDjw0+qjdM9hxowOirQSHPCljWwd8scW0tRFHyXAQSE/yBWrJ3EPuEiZlJUoth9ac0KLbHM+wSSkWHttiA==", + "dependencies": { + "plausible-tracker": "^0.3.4" + } + }, "node_modules/vue-pursue": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/vue-pursue/-/vue-pursue-0.1.3.tgz", @@ -11268,9 +11291,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001306", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001306.tgz", - "integrity": "sha512-Wd1OuggRzg1rbnM5hv1wXs2VkxJH/AA+LuudlIqvZiCvivF+wJJe2mgBZC8gPMgI7D76PP5CTx8Luvaqc1V6OQ==", + "version": "1.0.30001399", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001399.tgz", + "integrity": "sha512-4vQ90tMKS+FkvuVWS5/QY1+d805ODxZiKFzsU8o/RsVJz49ZSRR8EjykLJbqhzdPgadbX6wB538wOzle3JniRA==", "dev": true }, "chalk": { @@ -13665,6 +13688,11 @@ "find-up": "^4.0.0" } }, + "plausible-tracker": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/plausible-tracker/-/plausible-tracker-0.3.8.tgz", + "integrity": "sha512-lmOWYQ7s9KOUJ1R+YTOR3HrjdbxIS2Z4de0P/Jx2dQPteznJl2eX3tXxKClpvbfyGP59B5bbhW8ftN59HbbFSg==" + }, "popper.js": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", @@ -14832,6 +14860,14 @@ "vue-style-loader": "^4.1.0" } }, + "vue-plausible": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/vue-plausible/-/vue-plausible-1.3.2.tgz", + "integrity": "sha512-7hdLrDjw0+qjdM9hxowOirQSHPCljWwd8scW0tRFHyXAQSE/yBWrJ3EPuEiZlJUoth9ac0KLbHM+wSSkWHttiA==", + "requires": { + "plausible-tracker": "^0.3.4" + } + }, "vue-pursue": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/vue-pursue/-/vue-pursue-0.1.3.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index 6ea3df42..3647fc23 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -75,6 +75,7 @@ "urijs": "^1.19.7", "v-tooltip": "2.0.2", "vue": "^2.6.10", + "vue-plausible": "^1.3.2", "vue-rx": "^6.0.1", "vue-slider-component": "^2.8.1", "vuex": "^3.6.2", diff --git a/src/frontend/src/article.ts b/src/frontend/src/article.ts index 22cdca49..2ff6919e 100644 --- a/src/frontend/src/article.ts +++ b/src/frontend/src/article.ts @@ -10,6 +10,8 @@ import HighchartsExportingData from 'highcharts/modules/export-data'; import HighchartsBoost from 'highcharts/modules/boost'; import URI from 'urijs'; +//@ts-ignore +import VuePlausible from 'vue-plausible/lib/esm/vue-plugin.js'; import * as RootStore from '@/store/article'; import ArticlePageComponent from '@/pages/article/ArticlePage.vue'; @@ -36,7 +38,17 @@ HighchartsExporting(Highcharts); HighchartsExportingData(Highcharts); HighchartsBoost(Highcharts); -Vue.use(HighchartsVue); +declare const PLAUSIBLE_DOMAIN: string|undefined; +declare const PLAUSIBLE_APIHOST: string|undefined; +if (PLAUSIBLE_DOMAIN && PLAUSIBLE_APIHOST) { + Vue.use(VuePlausible, { + domain: PLAUSIBLE_DOMAIN, + trackLocalhost: true, + apiHost: PLAUSIBLE_APIHOST, + }); + //@ts-ignore + Vue.$plausible.trackPageview(); +}Vue.use(HighchartsVue); $(document).ready(() => { RootStore.init(); diff --git a/src/frontend/src/search.tsx b/src/frontend/src/search.tsx index 48e3adfd..26b3c717 100644 --- a/src/frontend/src/search.tsx +++ b/src/frontend/src/search.tsx @@ -7,6 +7,8 @@ import Vue from 'vue'; // @ts-ignore import VTooltip from 'v-tooltip'; +//@ts-ignore +import VuePlausible from 'vue-plausible/lib/esm/vue-plugin.js'; import Filters from '@/components/filters'; @@ -135,7 +137,7 @@ function initQueryBuilder() { // -------------- Vue.config.productionTip = false; Vue.config.errorHandler = (err, vm, info) => { - if (err.message !== '[vuex] Do not mutate vuex store state outside mutation handlers.') { // already logged and annoying + if (!err.message.includes('[vuex]' /* do not mutate vuex store state outside mutation handlers */)) { // already logged and annoying ga('send', 'exception', { exDescription: err.message, exFatal: true }); console.error(err); } else { @@ -162,6 +164,18 @@ Vue.mixin({ // tslint:enable }); + +declare const PLAUSIBLE_DOMAIN: string|undefined; +declare const PLAUSIBLE_APIHOST: string|undefined; +if (PLAUSIBLE_DOMAIN && PLAUSIBLE_APIHOST) { + Vue.use(VuePlausible, { + domain: PLAUSIBLE_DOMAIN, + trackLocalhost: true, + apiHost: PLAUSIBLE_APIHOST, + }); + //@ts-ignore + Vue.$plausible.trackPageview(); +} Vue.use(Filters); Vue.use(VTooltip, { popover: { diff --git a/src/frontend/src/store/search/streams.ts b/src/frontend/src/store/search/streams.ts index 3f38df76..d86b8aea 100644 --- a/src/frontend/src/store/search/streams.ts +++ b/src/frontend/src/store/search/streams.ts @@ -23,6 +23,7 @@ import * as Api from '@/api'; import * as BLTypes from '@/types/blacklabtypes'; import jsonStableStringify from 'json-stable-stringify'; import { debugLog } from '@/utils/debug'; +import Vue from 'vue'; type QueryState = { params?: BLTypes.BLSearchParameters, @@ -307,9 +308,7 @@ export default () => { query: state.query } }), - v => { - url$.next(cloneDeep(v)); - }, + v => url$.next(cloneDeep(v)), { immediate: true, deep: true diff --git a/src/main/java/nl/inl/corpuswebsite/utils/WebsiteConfig.java b/src/main/java/nl/inl/corpuswebsite/utils/WebsiteConfig.java index 3442e52c..d66f844d 100644 --- a/src/main/java/nl/inl/corpuswebsite/utils/WebsiteConfig.java +++ b/src/main/java/nl/inl/corpuswebsite/utils/WebsiteConfig.java @@ -94,6 +94,9 @@ public String toString() { /** Google analytics key, analytics are disabled if not provided */ private final Optional analyticsKey; + private final Optional plausibleDomain; + private final Optional plausibleApiHost; + /** Link to put in the top bar */ private final List linksInTopBar; @@ -111,55 +114,59 @@ public String toString() { */ public WebsiteConfig(File configFile, Optional corpusConfig, String contextPath) throws ConfigurationException { - Parameters parameters = new Parameters(); - ConfigurationBuilder cb = new FileBasedConfigurationBuilder<>(XMLConfiguration.class) - .configure(parameters.fileBased() - .setFile(configFile) - .setListDelimiterHandler(new DisabledListDelimiterHandler()) - .setPrefixLookups(new HashMap(ConfigurationInterpolator.getDefaultPrefixLookups()) {{ - put("request", key -> { - switch (key) { - case "contextPath": return contextPath; - case "corpusId": return corpusConfig.map(CorpusConfig::getCorpusId).orElse(""); // don't return null, or the interpolation string (${request:corpusId}) will be rendered - case "corpusPath": return contextPath + corpusConfig.map(c -> "/" + c.getCorpusId()).orElse(""); - default: return key; - } - }); - }})); - // Load the specified config file - XMLConfiguration xmlConfig = cb.getConfiguration(); - - corpusId = corpusConfig.map(CorpusConfig::getCorpusId); - // Can be specified in multiple places: search.xml, corpusConfig (in blacklab), or as a fallback, just the corpusname with some capitalization and any username removed. - corpusDisplayName = Arrays.asList( - xmlConfig.getString("InterfaceProperties.DisplayName"), - corpusConfig.flatMap(CorpusConfig::getDisplayName).orElse(""), - MainServlet.getCorpusName(corpusId).orElse("") - ) - .stream().map(StringUtils::trimToNull).filter(s -> s != null).findFirst(); - corpusOwner = MainServlet.getCorpusOwner(corpusId); - pathToCustomJs = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.CustomJs"))); - pathToCustomCss = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.CustomCss"))); - pathToFaviconDir = xmlConfig.getString("InterfaceProperties.FaviconDir", contextPath + "/img"); - propColumns = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.PropColumns"))); - pagination = xmlConfig.getBoolean("InterfaceProperties.Article.Pagination", false); - pageSize = Math.max(1, xmlConfig.getInt("InterfaceProperties.Article.PageSize", 1000)); - analyticsKey = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.Analytics.Key"))); - linksInTopBar = Stream.concat( - corpusOwner.isPresent() ? Stream.of(new LinkInTopBar("My corpora", contextPath + "/corpora", false)) : Stream.empty(), - xmlConfig.configurationsAt("InterfaceProperties.NavLinks.Link").stream().map(sub -> { - String label = sub.getString(""); - String href = StringUtils.defaultIfEmpty(sub.getString("[@value]"), label); - boolean newWindow = sub.getBoolean("[@newWindow]", false); - boolean relative = sub.getBoolean("[@relative]", false); // No longer supported, keep around for compatibility - if (relative) - href = contextPath + "/" + href; - - return new LinkInTopBar(label, href, newWindow); - }) - ).collect(Collectors.toList()); - xsltParameters = xmlConfig.configurationsAt("XsltParameters.XsltParameter").stream() - .collect(Collectors.toMap(sub -> sub.getString("[@name]"), sub -> sub.getString("[@value]"))); + Parameters parameters = new Parameters(); + ConfigurationBuilder cb = new FileBasedConfigurationBuilder<>(XMLConfiguration.class) + .configure(parameters.fileBased() + .setFile(configFile) + .setListDelimiterHandler(new DisabledListDelimiterHandler()) + .setPrefixLookups(new HashMap(ConfigurationInterpolator.getDefaultPrefixLookups()) {{ + put("request", key -> { + switch (key) { + case "contextPath": return contextPath; + case "corpusId": return corpusConfig.map(CorpusConfig::getCorpusId).orElse(""); // don't return null, or the interpolation string (${request:corpusId}) will be rendered + case "corpusPath": return contextPath + corpusConfig.map(c -> "/" + c.getCorpusId()).orElse(""); + default: return key; + } + }); + }})); + // Load the specified config file + XMLConfiguration xmlConfig = cb.getConfiguration(); + + corpusId = corpusConfig.map(CorpusConfig::getCorpusId); + // Can be specified in multiple places: search.xml, corpusConfig (in blacklab), or as a fallback, just the corpusname with some capitalization and any username removed. + corpusDisplayName = Arrays.asList( + xmlConfig.getString("InterfaceProperties.DisplayName"), + corpusConfig.flatMap(CorpusConfig::getDisplayName).orElse(""), + MainServlet.getCorpusName(corpusId).orElse("") + ) + .stream().map(StringUtils::trimToNull).filter(s -> s != null).findFirst(); + corpusOwner = MainServlet.getCorpusOwner(corpusId); + pathToCustomJs = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.CustomJs"))); + pathToCustomCss = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.CustomCss"))); + pathToFaviconDir = xmlConfig.getString("InterfaceProperties.FaviconDir", contextPath + "/img"); + propColumns = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.PropColumns"))); + pagination = xmlConfig.getBoolean("InterfaceProperties.Article.Pagination", false); + pageSize = Math.max(1, xmlConfig.getInt("InterfaceProperties.Article.PageSize", 1000)); + analyticsKey = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.Analytics.Key"))); + linksInTopBar = Stream.concat( + corpusOwner.isPresent() ? Stream.of(new LinkInTopBar("My corpora", contextPath + "/corpora", false)) : Stream.empty(), + xmlConfig.configurationsAt("InterfaceProperties.NavLinks.Link").stream().map(sub -> { + String label = sub.getString(""); + String href = StringUtils.defaultIfEmpty(sub.getString("[@value]"), label); + boolean newWindow = sub.getBoolean("[@newWindow]", false); + boolean relative = sub.getBoolean("[@relative]", false); // No longer supported, keep around for compatibility + if (relative) + href = contextPath + "/" + href; + + return new LinkInTopBar(label, href, newWindow); + }) + ).collect(Collectors.toList()); + xsltParameters = xmlConfig.configurationsAt("XsltParameters.XsltParameter").stream() + .collect(Collectors.toMap(sub -> sub.getString("[@name]"), sub -> sub.getString("[@value]"))); + + // plausible + this.plausibleDomain = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.Plausible.domain"))); + this.plausibleApiHost = Optional.ofNullable(StringUtils.trimToNull(xmlConfig.getString("InterfaceProperties.Plausible.apiHost"))); } public Optional getCorpusId() { @@ -213,10 +220,18 @@ public boolean usePagination() { } public int getPageSize() { - return pageSize; + return pageSize; } public Optional getAnalyticsKey() { return analyticsKey; } + + public Optional getPlausibleDomain() { + return plausibleDomain; + } + + public Optional getPlausibleApiHost() { + return plausibleApiHost; + } } diff --git a/src/main/resources/interface-default/search.xml b/src/main/resources/interface-default/search.xml index a31b2b7f..f2425ab7 100644 --- a/src/main/resources/interface-default/search.xml +++ b/src/main/resources/interface-default/search.xml @@ -65,6 +65,12 @@ --> + + +