diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8828a28..05c9461ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 6.8.0 + +### Added +- Link previews (Smart Picker) render the full live report as chart, table or KPI instead of a static card. +- Link previews render the first page of a panorama with all its report, text and picture widgets. + +### Fixed +- Reports and panoramas shared with the current user resolve correctly in link previews instead of showing "Report not found". +- The admin setting `link_preview_enabled` disables analytics link previews again. + ## 6.7.1 - 2026-07-19 ### Fixed - Keep table footer totals aligned with their columns after column reordering. diff --git a/css/reference.css b/css/reference.css new file mode 100644 index 000000000..7d255a349 --- /dev/null +++ b/css/reference.css @@ -0,0 +1,219 @@ +/** + * Analytics + * + * SPDX-FileCopyrightText: 2026 Marcel Scherello + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +.analytics-reference-widget { + display: flex; + flex-direction: column; + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large, 8px); + background-color: var(--color-main-background); + color: var(--color-main-text); + overflow: hidden; +} + +.analytics-reference-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); +} + +.analytics-reference-header img { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.analytics-reference-header a, +.analytics-reference-header span { + font-weight: 600; + color: var(--color-main-text); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analytics-reference-header a:hover { + text-decoration: underline; +} + +.analytics-reference-body { + position: relative; + height: 350px; + padding: 8px; + box-sizing: border-box; +} + +/* combined chart + table view: chart on top, scrollable table below */ +.analytics-reference-body.analytics-reference-ct { + height: auto; +} + +/* table-only view: grow with the content, but stay scrollable within a cap */ +.analytics-reference-body.analytics-reference-scroll { + height: auto; + max-height: 350px; + overflow-y: auto; + overflow-x: auto; +} + +.analytics-reference-chart-area { + position: relative; + height: 334px; +} + +.analytics-reference-ct .analytics-reference-chart-area { + height: 260px; +} + +.analytics-reference-table-area { + max-height: 260px; + overflow-y: auto; + overflow-x: auto; + margin-top: 8px; +} + +.analytics-reference-body table { + width: 100%; +} + +.analytics-reference-message { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-maxcontrast, var(--color-text-lighter)); +} + +/* panorama grid: first page of the panorama, one cell per widget */ +.analytics-reference-body.analytics-reference-panorama { + height: auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 8px; +} + +.analytics-reference-panorama-cell { + position: relative; + height: 240px; + padding: 4px; + box-sizing: border-box; + border: 1px solid var(--color-border); + border-radius: var(--border-radius, 4px); + overflow: hidden; +} + +.analytics-reference-panorama-cell-title { + height: 24px; + padding-left: 6px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analytics-reference-panorama-cell-content { + position: relative; + height: calc(100% - 24px); + overflow: hidden; +} + +.analytics-reference-panorama-cell-content.analytics-reference-scroll { + overflow-y: auto; +} + +.analytics-reference-panorama-text { + padding: 6px; + white-space: pre-wrap; + overflow-y: auto; + height: 100%; +} + +.analytics-reference-panorama-picture { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.analytics-reference-panorama-picture img { + max-width: 90%; + max-height: 90%; +} + +.analytics-reference-footer { + padding: 4px 12px; + border-top: 1px solid var(--color-border); + font-size: 0.9em; +} + +.analytics-reference-footer a { + color: var(--color-text-maxcontrast, var(--color-text-lighter)); +} + +/* static fallback card (also used while the rich object carries no id) */ +.analytics-reference-fallback { + display: flex; + color: var(--color-main-text); +} + +.analytics-reference-fallback img { + width: 20%; + padding: 20px; + opacity: .5; + box-sizing: border-box; +} + +.analytics-reference-fallback-content { + padding: 10px; + width: 75%; +} + +.analytics-reference-fallback-title { + font-weight: 600; +} + +.analytics-reference-fallback-subheader { + margin-top: 1em; +} + +/* KPI styles from style.css, which is not loaded in the reference context */ +.analytics-reference-widget .kpiWidget { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + border-radius: 8px; + overflow: hidden; +} + +.analytics-reference-widget .kpiWidgetContent { + text-align: center; +} + +.analytics-reference-widget .kpiWidgetTitel { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.analytics-reference-widget .kpiWidgetValue { + font-size: 3.5rem; + font-weight: 400; +} + +.analytics-reference-panorama-cell .kpiWidgetTitel { + font-size: 1.1rem; +} + +.analytics-reference-panorama-cell .kpiWidgetValue { + font-size: 2.2rem; +} diff --git a/js/reference.js b/js/reference.js index 00f73c53f..3d366c88c 100644 --- a/js/reference.js +++ b/js/reference.js @@ -6,6 +6,10 @@ */ /** global: OC */ +/** global: OCA */ +/** global: Chart */ +/** global: t */ +/** global: _registerWidget */ 'use strict'; @@ -27,62 +31,781 @@ const getSafeReferenceUrl = function (value) { } }; -document.addEventListener('DOMContentLoaded', function () { - OCA.Analytics.Reference.init(); -}) - +if (!window.OCA) { + window.OCA = {}; +} if (!OCA.Analytics) { /** * @namespace */ OCA.Analytics = {}; } + +// minimal namespace state required by visualization.js when it runs outside the main app +OCA.Analytics.chartObject = OCA.Analytics.chartObject || null; +OCA.Analytics.tableObject = OCA.Analytics.tableObject || {}; +OCA.Analytics.unsavedChanges = OCA.Analytics.unsavedChanges || null; +OCA.Analytics.chartTypeMapping = OCA.Analytics.chartTypeMapping || { + 'datetime': 'line', + 'column': 'bar', + 'columnSt': 'bar', // map stacked type also to base type; needed in filter + 'columnSt100': 'bar', // map stacked type also to base type; needed in filter + 'area': 'line', + 'line': 'line', + 'doughnut': 'doughnut', + 'funnel': 'funnel' +}; + /** * @namespace OCA.Analytics.Reference */ OCA.Analytics.Reference = { + PANORAMA_CONTENT_TYPE_REPORT: 0, + PANORAMA_CONTENT_TYPE_TEXT: 1, + PANORAMA_CONTENT_TYPE_PICTURE: 2, + + instanceCounter: 0, + // widget root element => {canvases: [], tableUids: []}, used by the destroy callback + widgetRegistry: new Map(), + coreAssetsPromise: null, + tableAssetsPromise: null, + scriptPromises: {}, + init: function () { + if (typeof _registerWidget !== 'function') { + return; + } _registerWidget('analytics', async (el, {richObjectType, richObject, accessible}) => { - const referenceUrl = getSafeReferenceUrl(richObject.url); - const imageUrl = getSafeReferenceUrl(richObject.image); - const widget = document.createElement(referenceUrl ? 'a' : 'div'); - widget.style.display = 'flex'; + await OCA.Analytics.Reference.renderWidget(el, richObject); + }, (el) => { + OCA.Analytics.Reference.destroyWidget(el); + }, {hasInteractiveView: false}); + }, + + // ************* + // *** rendering + // ************* + + renderWidget: async function (el, richObject) { + if (!richObject + || !richObject.id + || richObject.found === false + || (richObject.item_type !== 'report' && richObject.item_type !== 'panorama') + ) { + // covers items the user cannot access and references cached before this widget existed + OCA.Analytics.Reference.renderStaticCard(el, richObject); + return; + } + + const widget = document.createElement('div'); + widget.classList.add('analytics-reference-widget'); + + const header = document.createElement('div'); + header.classList.add('analytics-reference-header'); + const icon = document.createElement('img'); + const iconUrl = getSafeReferenceUrl(richObject.image); + if (iconUrl) { + icon.setAttribute('src', iconUrl); + icon.setAttribute('alt', ''); + header.appendChild(icon); + } + const referenceUrl = getSafeReferenceUrl(richObject.url); + const headerLink = document.createElement(referenceUrl ? 'a' : 'span'); + if (referenceUrl) { + headerLink.setAttribute('href', referenceUrl); + headerLink.setAttribute('target', '_blank'); + headerLink.setAttribute('rel', 'noopener noreferrer'); + } + headerLink.textContent = richObject.subheader || richObject.name || ''; + header.appendChild(headerLink); + widget.appendChild(header); + + const body = document.createElement('div'); + body.classList.add('analytics-reference-body'); + body.appendChild(OCA.Analytics.Reference.buildLoadingIndicator()); + widget.appendChild(body); + + el.textContent = ''; + el.appendChild(widget); + OCA.Analytics.Reference.widgetRegistry.set(el, {canvases: [], tableUids: []}); + + try { + if (richObject.item_type === 'panorama') { + await OCA.Analytics.Reference.renderPanorama(el, richObject, headerLink, body); + } else { + await OCA.Analytics.Reference.renderReport(el, richObject, headerLink, body); + } + } catch (error) { + // asset loading blocked, report deleted, permission revoked, … + OCA.Analytics.Reference.destroyWidget(el); + OCA.Analytics.Reference.renderStaticCard(el, richObject); + } + }, + + renderReport: async function (el, richObject, headerLink, body) { + await OCA.Analytics.Reference.ensureCoreAssets(); + let data = await OCA.Analytics.Reference.fetchReportData( + OC.generateUrl('apps/analytics/data/' + richObject.id, true), + 'analytics-report-' + richObject.id + ); + + data = OCA.Analytics.Reference.processReceivedData(data); + if (data.options && data.options.name) { + headerLink.textContent = data.options.name; + } + + if (data.status === 'nodata' || !Array.isArray(data.data) || data.data.length === 0) { + body.replaceChildren(OCA.Analytics.Reference.buildMessage(t('analytics', 'No data found'))); + return; + } + + data.data = OCA.Analytics.Visualization.formatDates(data.data); + await OCA.Analytics.Reference.renderVisualization(el, body, data, false); + }, + + renderPanorama: async function (el, richObject, headerLink, body) { + await OCA.Analytics.Reference.ensureCoreAssets(); + const meta = await OCA.Analytics.Reference.fetchJson( + OC.generateUrl('apps/analytics/panorama/' + richObject.id, true) + ); + if (!meta || !meta.id) { + throw new Error('panorama not available'); + } + if (meta.name) { + headerLink.textContent = meta.name; + } + + let pages = meta.pages; + if (typeof pages === 'string') { + pages = JSON.parse(pages); + } + if (!Array.isArray(pages) || pages.length === 0 + || !Array.isArray(pages[0].reports) || pages[0].reports.length === 0) { + body.replaceChildren(OCA.Analytics.Reference.buildMessage(t('analytics', 'No data found'))); + return; + } + + body.replaceChildren(); + body.classList.add('analytics-reference-panorama'); + + const cellPromises = pages[0].reports.map((item) => { + const cell = document.createElement('div'); + cell.classList.add('analytics-reference-panorama-cell'); + body.appendChild(cell); + return OCA.Analytics.Reference.renderPanoramaCell(el, cell, item); + }); + if (pages.length > 1) { + const footer = document.createElement('div'); + footer.classList.add('analytics-reference-footer'); + const referenceUrl = getSafeReferenceUrl(richObject.url); + const link = document.createElement(referenceUrl ? 'a' : 'span'); if (referenceUrl) { - widget.setAttribute('href', referenceUrl); - widget.setAttribute('target', '_blank'); - widget.setAttribute('rel', 'noopener noreferrer'); + link.setAttribute('href', referenceUrl); + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); } + link.textContent = t('analytics', 'Page') + ' 1/' + pages.length; + footer.appendChild(link); + body.parentNode.appendChild(footer); + } + + // a failing single cell must not tear down the whole panorama widget + await Promise.allSettled(cellPromises); + }, + + renderPanoramaCell: async function (el, cell, item) { + if (item === null || item === undefined) { + return; + } + const contentType = parseInt(item['type']); + const contentValue = item['value']; - const content = document.createElement('div'); - content.style.padding = '10px'; - content.style.width = imageUrl ? '75%' : '100%'; - - const title = document.createElement('div'); - title.style.fontWeight = '600'; - title.textContent = richObject.name || ''; - - const subheader = document.createElement('div'); - subheader.style.marginTop = '1em'; - subheader.textContent = richObject.subheader || ''; - - content.appendChild(title); - content.appendChild(subheader); - - if (imageUrl) { - const image = document.createElement('img'); - image.setAttribute('src', imageUrl); - image.setAttribute('alt', ''); - image.style.width = '20%'; - image.style.padding = '20px'; - image.style.opacity = '.5'; - widget.appendChild(image); + if (contentType === OCA.Analytics.Reference.PANORAMA_CONTENT_TYPE_TEXT) { + const text = document.createElement('div'); + text.classList.add('analytics-reference-panorama-text'); + // DOMParser neither executes scripts nor loads resources; plain text is enough here + text.textContent = new DOMParser().parseFromString(String(contentValue ?? ''), 'text/html').body.textContent || ''; + cell.appendChild(text); + return; + } + + if (contentType === OCA.Analytics.Reference.PANORAMA_CONTENT_TYPE_PICTURE) { + const pictureContainer = document.createElement('div'); + pictureContainer.classList.add('analytics-reference-panorama-picture'); + const image = document.createElement('img'); + image.setAttribute('alt', ''); + image.src = OC.generateUrl('/core/preview') + '?fileId=' + encodeURIComponent(contentValue) + '&x=300&y=300&a=true'; + pictureContainer.appendChild(image); + cell.appendChild(pictureContainer); + return; + } + + if (contentType !== OCA.Analytics.Reference.PANORAMA_CONTENT_TYPE_REPORT) { + return; + } + + const title = document.createElement('div'); + title.classList.add('analytics-reference-panorama-cell-title'); + cell.appendChild(title); + const content = document.createElement('div'); + content.classList.add('analytics-reference-panorama-cell-content'); + content.appendChild(OCA.Analytics.Reference.buildLoadingIndicator()); + cell.appendChild(content); + + try { + const reportId = parseInt(contentValue); + let data = await OCA.Analytics.Reference.fetchReportData( + OC.generateUrl('apps/analytics/data/pa/' + reportId, true), + 'analytics-report-' + reportId + ); + data = OCA.Analytics.Reference.processReceivedData(data); + title.textContent = (data.options && data.options.name) || ''; + + if (data.status === 'nodata' || !Array.isArray(data.data) || data.data.length === 0) { + content.replaceChildren(OCA.Analytics.Reference.buildMessage(t('analytics', 'No data found'))); + return; } + data.data = OCA.Analytics.Visualization.formatDates(data.data); + + const legend = item?.options?.legend; + await OCA.Analytics.Reference.renderVisualization(el, content, data, true, legend); + } catch (error) { + content.replaceChildren(OCA.Analytics.Reference.buildMessage(t('analytics', 'The report is not available anymore'))); + } + }, + + /** + * dispatch a processed data payload to chart / KPI / table rendering + * compact = panorama cell; legend only applies to compact charts + */ + renderVisualization: async function (el, container, data, compact, legend) { + const visualization = data.options.visualization; + const registryEntry = OCA.Analytics.Reference.widgetRegistry.get(el); + + if (visualization === 'table' && data.data.length === 1) { + // KPI view, same heuristic as the panorama + const kpi = document.createElement('div'); + container.replaceChildren(kpi); + OCA.Analytics.Visualization.buildKpiDisplay(kpi, data, false, OCA.Analytics.Reference.nextUid()); + return; + } + + if (visualization === 'table') { + await OCA.Analytics.Reference.ensureTableAssets(); + container.replaceChildren(); + container.classList.add('analytics-reference-scroll'); + OCA.Analytics.Reference.buildTable(container, data, registryEntry); + return; + } - widget.appendChild(content); + if (visualization === 'ct') { + await OCA.Analytics.Reference.ensureTableAssets(); + container.replaceChildren(); + container.classList.add('analytics-reference-ct'); + const chartArea = document.createElement('div'); + chartArea.classList.add('analytics-reference-chart-area'); + container.appendChild(chartArea); + OCA.Analytics.Reference.buildChart(chartArea, data, compact, legend, registryEntry); + const tableArea = document.createElement('div'); + tableArea.classList.add('analytics-reference-table-area'); + container.appendChild(tableArea); + OCA.Analytics.Reference.buildTable(tableArea, data, registryEntry); + return; + } + + // 'chart' and anything unknown + container.replaceChildren(); + OCA.Analytics.Reference.buildChart(container, data, compact, legend, registryEntry); + }, + + buildChart: function (container, data, compact, legend, registryEntry) { + const canvas = document.createElement('canvas'); + canvas.id = 'analyticsReferenceChart' + OCA.Analytics.Reference.nextUid(); + container.appendChild(canvas); + if (registryEntry) { + registryEntry.canvases.push(canvas); + } - el.textContent = ''; - el.appendChild(widget); - }, () => {}, { hasInteractiveView: false }); + const chartOptions = compact + ? OCA.Analytics.Reference.getCompactChartOptions(legend) + : OCA.Analytics.Reference.getDefaultChartOptions(); + OCA.Analytics.Visualization.buildChart(canvas.getContext('2d'), data, chartOptions); }, + + buildTable: function (container, data, registryEntry) { + const table = document.createElement('table'); + const uid = OCA.Analytics.Reference.nextUid(); + table.id = 'analyticsReferenceTable' + uid; + container.appendChild(table); + OCA.Analytics.Visualization.buildDataTable(table, data, true, uid); + if (registryEntry) { + registryEntry.tableUids.push(uid); + } + }, + + // unique per widget instance; buildDataTable/buildKpiDisplay reduce the uid to its digits + nextUid: function () { + return String(++OCA.Analytics.Reference.instanceCounter); + }, + + destroyWidget: function (el) { + const entry = OCA.Analytics.Reference.widgetRegistry.get(el); + if (!entry) { + return; + } + entry.canvases.forEach((canvas) => { + try { + const chart = window.Chart ? Chart.getChart(canvas) : null; + if (chart) { + chart.destroy(); + } + } catch (error) { + } + }); + entry.tableUids.forEach((uid) => { + const numericUid = parseInt(String(uid).replace(/[^0-9]+/g, ''), 10); + const tableObject = OCA.Analytics.tableObject && OCA.Analytics.tableObject[numericUid]; + if (tableObject && typeof tableObject.destroy === 'function') { + try { + tableObject.destroy(); + } catch (error) { + } + delete OCA.Analytics.tableObject[numericUid]; + } + }); + OCA.Analytics.Reference.widgetRegistry.delete(el); + }, + + buildLoadingIndicator: function () { + const loading = document.createElement('div'); + loading.classList.add('icon-loading'); + loading.style.height = '100%'; + return loading; + }, + + buildMessage: function (message) { + const div = document.createElement('div'); + div.classList.add('analytics-reference-message'); + div.textContent = message; + return div; + }, + + renderStaticCard: function (el, richObject) { + const referenceUrl = getSafeReferenceUrl(richObject?.url); + const imageUrl = getSafeReferenceUrl(richObject?.image); + const widget = document.createElement(referenceUrl ? 'a' : 'div'); + widget.classList.add('analytics-reference-fallback'); + widget.style.display = 'flex'; + + if (referenceUrl) { + widget.setAttribute('href', referenceUrl); + widget.setAttribute('target', '_blank'); + widget.setAttribute('rel', 'noopener noreferrer'); + } + + const content = document.createElement('div'); + content.classList.add('analytics-reference-fallback-content'); + content.style.padding = '10px'; + content.style.width = imageUrl ? '75%' : '100%'; + + const title = document.createElement('div'); + title.classList.add('analytics-reference-fallback-title'); + title.style.fontWeight = '600'; + title.textContent = richObject?.name || ''; + + const subheader = document.createElement('div'); + subheader.classList.add('analytics-reference-fallback-subheader'); + subheader.style.marginTop = '1em'; + subheader.textContent = richObject?.subheader || ''; + + content.appendChild(title); + content.appendChild(subheader); + + if (imageUrl) { + const image = document.createElement('img'); + image.setAttribute('src', imageUrl); + image.setAttribute('alt', ''); + image.style.width = '20%'; + image.style.padding = '20px'; + image.style.opacity = '.5'; + widget.appendChild(image); + } + + widget.appendChild(content); + + el.textContent = ''; + el.appendChild(widget); + }, + + // ************* + // *** data access + // ************* + + fetchJson: function (url) { + return new Promise(function (resolve, reject) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.setRequestHeader('requesttoken', OC.requestToken); + xhr.setRequestHeader('OCS-APIREQUEST', 'true'); + xhr.onreadystatechange = function () { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return; + } + if (xhr.status === 200) { + try { + resolve(JSON.parse(xhr.response)); + } catch (e) { + reject(e); + } + } else { + reject(new Error('request failed: ' + xhr.status)); + } + }; + xhr.onerror = function () { + reject(new Error('request failed')); + }; + xhr.send(); + }); + }, + + // ETag / localStorage caching identical to the dashboard widget, but without + // its 20-row truncation - the reference widget shows the full report + fetchReportData: function (url, cacheKey) { + const storage = OCA.Analytics.Reference.getLocalStorage(); + + let cachedData = null; + let cachedVersion = null; + if (storage) { + try { + const cachedEntry = storage.getItem(cacheKey); + if (cachedEntry) { + const parsed = JSON.parse(cachedEntry); + cachedData = parsed.data; + cachedVersion = parsed.version; + } + } catch (e) { + try { + storage.removeItem(cacheKey); + } catch (removeError) { + } + } + } + + return new Promise(function (resolve, reject) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.setRequestHeader('requesttoken', OC.requestToken); + xhr.setRequestHeader('OCS-APIREQUEST', 'true'); + + if (cachedVersion) { + xhr.setRequestHeader('If-None-Match', cachedVersion); + } + + xhr.onreadystatechange = function () { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return; + } + if (xhr.status === 200) { + let data; + try { + data = JSON.parse(xhr.response); + } catch (e) { + reject(e); + return; + } + + const newVersion = xhr.getResponseHeader('ETag') || null; + const cacheable = xhr.getResponseHeader('X-Analytics-Cacheable') === 'true'; + if (cacheable && newVersion && storage) { + try { + storage.setItem(cacheKey, JSON.stringify({data: data, version: newVersion})); + } catch (e) { + } + } + resolve(data); + } else if (xhr.status === 304 && cachedData) { + resolve(cachedData); + } else { + reject(new Error('request failed: ' + xhr.status)); + } + }; + xhr.onerror = function () { + reject(new Error('request failed')); + }; + xhr.send(); + }); + }, + + getLocalStorage: function () { + if (typeof window === 'undefined') { + return null; + } + try { + return typeof window.localStorage === 'undefined' ? null : window.localStorage; + } catch (e) { + return null; + } + }, + + processReceivedData: function (data) { + data.options.chartoptions = OCA.Analytics.ChartOptions.parseAndNormalize(data.options.chartoptions); + + const parsedDataOptions = OCA.Analytics.ChartOptions.safeParse(data.options.dataoptions, []); + data.options.dataoptions = Array.isArray(parsedDataOptions) ? parsedDataOptions : []; + + const parsedFilterOptions = OCA.Analytics.ChartOptions.safeParse(data.options.filteroptions, {}); + data.options.filteroptions = ( + parsedFilterOptions !== null + && typeof parsedFilterOptions === 'object' + && !Array.isArray(parsedFilterOptions) + ) ? parsedFilterOptions : {}; + + const parsedTableOptions = OCA.Analytics.ChartOptions.safeParse(data.options.tableoptions, {}); + data.options.tableoptions = (parsedTableOptions !== null && typeof parsedTableOptions === 'object') ? parsedTableOptions : {}; + + // if the user uses a special time parser (e.g. DD.MM), the data needs to be sorted differently + data = OCA.Analytics.Visualization.sortDates(data); + data = OCA.Analytics.Visualization.applyTimeAggregation(data); + data = OCA.Analytics.Visualization.applyTopN(data); + + return data; + }, + + // ************* + // *** chart options + // ************* + + // full view: axes, grid and stored report options apply (same as the public report page) + getDefaultChartOptions: function () { + return { + maintainAspectRatio: false, + responsive: true, + scales: { + 'primary': { + type: 'linear', + stacked: false, + position: 'left', + display: true, + grid: { + display: true, + }, + ticks: { + callback: function (value) { + return value.toLocaleString(); + }, + }, + }, + 'secondary': { + type: 'linear', + stacked: false, + position: 'right', + display: false, + grid: { + display: false, + }, + ticks: { + callback: function (value) { + return value.toLocaleString(); + }, + }, + }, + 'x': { + type: 'category', + time: { + parser: 'YYYY-MM-DD HH:mm', + tooltipFormat: 'LL', + }, + distribution: 'linear', + grid: { + display: false + }, + display: true, + }, + }, + animation: { + duration: 0 // general animation time + }, + interaction: { + mode: 'x', + intersect: false, + }, + plugins: { + tooltip: OCA.Analytics.Visualization.getSharedTooltipOptions(), + datalabels: { + display: false, + formatter: (value, ctx) => { + let sum = 0; + let dataArr = ctx.chart.data.datasets[0].data; + dataArr.map(data => { + sum += data; + }); + value = (value * 100 / sum).toFixed(0); + if (value > 5) { + return value + "%"; + } else { + return ''; + } + }, + }, + }, + }; + }, + + // panorama cells: compact like the panorama page (no grid lines, optional legend) + getCompactChartOptions: function (legend) { + const options = { + devicePixelRatio: 2, + maintainAspectRatio: false, + responsive: true, + scales: { + 'primary': { + stacked: false, + position: 'left', + display: true, + grid: { + display: false, + }, + }, + 'secondary': { + stacked: false, + position: 'right', + display: false, + grid: { + display: false, + }, + }, + 'x': { + type: 'category', + distribution: 'linear', + grid: { + display: false + }, + display: true, + }, + }, + animation: { + duration: 0 // general animation time + }, + interaction: { + mode: 'x', + intersect: false, + }, + plugins: { + legend: { + display: true, + }, + tooltip: OCA.Analytics.Visualization.getSharedTooltipOptions(), + datalabels: { + display: false, + formatter: (value, ctx) => { + let sum = 0; + let dataArr = ctx.chart.data.datasets[0].data; + dataArr.map(data => { + sum += data; + }); + value = (value * 100 / sum).toFixed(0); + if (value > 5) { + return value + "%"; + } else { + return ''; + } + }, + } + }, + }; + if (legend !== undefined) { + options.plugins.legend.display = legend; + } + return options; + }, + + // ************* + // *** lazy asset loading + // ************* + + // the chart stack (~600KB) is only loaded once an analytics reference is actually + // rendered, not on every page that might show references (Talk, Text, Tables) + ensureCoreAssets: function () { + if (OCA.Analytics.Reference.coreAssetsPromise) { + return OCA.Analytics.Reference.coreAssetsPromise; + } + const load = OCA.Analytics.Reference.loadScript; + OCA.Analytics.Reference.coreAssetsPromise = Promise.all([ + load('3rdParty/moment.min', () => window.moment), + load('3rdParty/cloner', () => window.cloner), + ]) + .then(() => load('3rdParty/chart.umd', () => window.Chart)) + .then(() => Promise.all([ + load('3rdParty/chartjs-adapter-moment'), + load('3rdParty/chartjs-plugin-datalabels.min', () => window.ChartDataLabels), + load('3rdParty/chartjs-plugin-funnel.min'), + load('3rdParty/chartjs-plugin-annotation.min'), + ])) + .then(() => load('chartOptions', () => OCA.Analytics.ChartOptions && OCA.Analytics.ChartOptions.parseAndNormalize)) + .then(() => load('visualization', () => OCA.Analytics.Visualization && OCA.Analytics.Visualization.buildChart)) + .then(() => { + // visualization.js event handlers call into modules of the main app + // which are not loaded in the reference context + OCA.Analytics.Filter = OCA.Analytics.Filter || {}; + OCA.Analytics.Filter.toggleSaveButtonDisplay = OCA.Analytics.Filter.toggleSaveButtonDisplay || function () {}; + OCA.Analytics.Filter.syncChartLegendSelections = OCA.Analytics.Filter.syncChartLegendSelections || function () {}; + OCA.Analytics.Report = OCA.Analytics.Report || {}; + OCA.Analytics.Report.hideReportMenu = OCA.Analytics.Report.hideReportMenu || function () {}; + }); + return OCA.Analytics.Reference.coreAssetsPromise; + }, + + ensureTableAssets: function () { + if (OCA.Analytics.Reference.tableAssetsPromise) { + return OCA.Analytics.Reference.tableAssetsPromise; + } + const load = OCA.Analytics.Reference.loadScript; + // Talk/Text may already ship a jQuery; never load a second one + OCA.Analytics.Reference.tableAssetsPromise = load('3rdParty/jquery.min', () => window.jQuery) + .then(() => load('3rdParty/datatables.min', () => window.DataTable && window.jQuery && window.jQuery.fn && window.jQuery.fn.dataTable)) + .then(() => OCA.Analytics.Reference.loadStyle('3rdParty/datatables.min')); + return OCA.Analytics.Reference.tableAssetsPromise; + }, + + loadScript: function (name, testFn) { + if (testFn && testFn()) { + return Promise.resolve(); + } + if (OCA.Analytics.Reference.scriptPromises[name]) { + return OCA.Analytics.Reference.scriptPromises[name]; + } + OCA.Analytics.Reference.scriptPromises[name] = new Promise(function (resolve, reject) { + const script = document.createElement('script'); + script.src = OC.filePath('analytics', 'js', name + '.js'); + script.onload = () => resolve(); + script.onerror = () => reject(new Error('could not load ' + name)); + document.head.appendChild(script); + }); + return OCA.Analytics.Reference.scriptPromises[name]; + }, + + loadStyle: function (name) { + const href = OC.filePath('analytics', 'css', name + '.css'); + if (document.querySelector('link[href="' + href + '"]')) { + return Promise.resolve(); + } + return new Promise(function (resolve) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + // a missing stylesheet only degrades the table styling + link.onload = () => resolve(); + link.onerror = () => resolve(); + document.head.appendChild(link); + }); + }, +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + OCA.Analytics.Reference.init(); + }); +} else { + OCA.Analytics.Reference.init(); } diff --git a/lib/Listener/ReferenceListener.php b/lib/Listener/ReferenceListener.php index 5896b5456..7a66df264 100644 --- a/lib/Listener/ReferenceListener.php +++ b/lib/Listener/ReferenceListener.php @@ -22,5 +22,6 @@ public function handle(Event $event): void } Util::addScript('analytics', 'reference'); + Util::addStyle('analytics', 'reference'); } } \ No newline at end of file diff --git a/lib/Reference/ReferenceProvider.php b/lib/Reference/ReferenceProvider.php index 6c8360d77..33d7ab01f 100644 --- a/lib/Reference/ReferenceProvider.php +++ b/lib/Reference/ReferenceProvider.php @@ -10,6 +10,7 @@ use OCA\Analytics\Service\ReportService; use OCA\Analytics\Service\PanoramaService; +use OCA\Analytics\Service\ShareService; use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; use OCP\Collaboration\Reference\ISearchableReferenceProvider; use OCP\Collaboration\Reference\Reference; @@ -32,6 +33,7 @@ class ReferenceProvider extends ADiscoverableReferenceProvider implements ISearc private LoggerInterface $logger; private ReportService $ReportService; private PanoramaService $PanoramaService; + private ShareService $ShareService; public function __construct(IConfig $config, LoggerInterface $logger, @@ -40,6 +42,7 @@ public function __construct(IConfig $config, ReferenceManager $referenceManager, ReportService $ReportService, PanoramaService $PanoramaService, + ShareService $ShareService, ?string $userId) { $this->userId = $userId; @@ -50,6 +53,7 @@ public function __construct(IConfig $config, $this->urlGenerator = $urlGenerator; $this->ReportService = $ReportService; $this->PanoramaService = $PanoramaService; + $this->ShareService = $ShareService; } public function getId(): string @@ -83,7 +87,7 @@ public function matchReference(string $referenceText): bool { $adminLinkPreviewEnabled = $this->config->getAppValue('analytics', 'link_preview_enabled', '1') === '1'; if (!$adminLinkPreviewEnabled) { - //return false; + return false; } return preg_match('~/apps/analytics/(?:r|pa)/~', $referenceText) === 1; } @@ -92,13 +96,24 @@ public function resolveReference(string $referenceText): ?IReference { if ($this->matchReference($referenceText)) { preg_match("/\d+$/", $referenceText, $matches); // get the last integer + $itemId = isset($matches[0]) ? (int)$matches[0] : 0; $isPanorama = str_contains($referenceText, '/pa/'); + $item = []; if ($isPanorama) { - $item = $this->PanoramaService->read((int)$matches[0]); + if ($itemId !== 0) { + // PanoramaService->read() already falls back to shared panoramas + $item = $this->PanoramaService->read($itemId); + } $icon = 'panorama.svg'; $type = $this->l10n->t('Panorama'); } else { - $item = $this->ReportService->read((int)$matches[0]); + if ($itemId !== 0) { + $item = $this->ReportService->read($itemId); + if (empty($item)) { + // fall back to reports shared with the current user + $item = $this->ShareService->getSharedReport($itemId); + } + } $icon = 'report.svg'; $type = $this->l10n->t('Report'); } @@ -121,7 +136,10 @@ public function resolveReference(string $referenceText): ?IReference 'name' => $name, 'subheader' => $subheader, 'url' => $referenceText, - 'image' => $imageUrl + 'image' => $imageUrl, + 'id' => $itemId, + 'item_type' => $isPanorama ? 'panorama' : 'report', + 'found' => !empty($item) ] ); return $reference; diff --git a/lib/Service/PanoramaService.php b/lib/Service/PanoramaService.php index 07b01d998..ac5867a79 100644 --- a/lib/Service/PanoramaService.php +++ b/lib/Service/PanoramaService.php @@ -98,6 +98,7 @@ public function index(): array { /** * get own report details + * falls back to panoramas shared with the current user * * @param int $panoramaId * @return array @@ -105,6 +106,15 @@ public function index(): array { */ public function read(int $panoramaId) { $ownReport = $this->PanoramaMapper->readOwn($panoramaId); + if (empty($ownReport)) { + $sharedPanorama = $this->ShareService->getSharedPanorama($panoramaId); + if (!empty($sharedPanorama)) { + // ToDo: panoramas do not have an edit logic. to be added later + $sharedPanorama['permissions'] = \OCP\Constants::PERMISSION_READ; + $keysToKeep = array('id', 'name', 'dataset', 'favorite', 'parent', 'type', 'pages', 'isShare', 'shareId', 'permissions'); + $ownReport = array_intersect_key($sharedPanorama, array_flip($keysToKeep)); + } + } return $ownReport; } diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index 9eaa06568..e33d49744 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -241,6 +241,24 @@ public function getSharedReport($reportId) { } } + /** + * get metadata of a panorama, shared with current user + * used to check if user is allowed to access current panorama + * + * @param $panoramaId + * @return array + * @throws Exception + */ + public function getSharedPanorama($panoramaId) { + $sharedPanoramas = $this->getSharedItems(self::SHARE_ITEM_TYPE_PANORAMA); + if (in_array($panoramaId, array_column($sharedPanoramas, "id"))) { + $key = array_search($panoramaId, array_column($sharedPanoramas, 'id')); + return $sharedPanoramas[$key]; + } else { + return []; + } + } + /** * get metadata of a report, shared with current user as part of a panorama * used to check if user is allowed to execute current report diff --git a/tests/Reference/ReferenceProviderTest.php b/tests/Reference/ReferenceProviderTest.php new file mode 100644 index 000000000..56e51d281 --- /dev/null +++ b/tests/Reference/ReferenceProviderTest.php @@ -0,0 +1,163 @@ +config = $this->createMock(IConfig::class); + $this->config->method('getAppValue')->willReturn('1'); + + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->urlGenerator->method('imagePath') + ->willReturnCallback(function ($app, $file) { + return '/apps/analytics/img/' . $file; + }); + $this->urlGenerator->method('getAbsoluteURL') + ->willReturnCallback(function ($url) { + return 'https://cloud.example.com' . $url; + }); + + $this->reportService = $this->createMock(ReportService::class); + $this->panoramaService = $this->createMock(PanoramaService::class); + $this->shareService = $this->createMock(ShareService::class); + } + + private function buildProvider(): ReferenceProvider { + return new ReferenceProvider( + $this->config, + new NullLogger(), + new FakeL10N(), + $this->urlGenerator, + $this->createMock(ReferenceManager::class), + $this->reportService, + $this->panoramaService, + $this->shareService, + 'testUser' + ); + } + + public function testMatchReference(): void { + $provider = $this->buildProvider(); + + $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/r/5')); + $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/pa/7')); + $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/files/')); + $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/analytics/')); + } + + public function testMatchReferenceRespectsAdminDisable(): void { + $this->config = $this->createMock(IConfig::class); + $this->config->method('getAppValue') + ->with('analytics', 'link_preview_enabled', '1') + ->willReturn('0'); + $provider = $this->buildProvider(); + + $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/analytics/r/5')); + } + + public function testResolveOwnReport(): void { + $this->reportService->expects($this->once()) + ->method('read') + ->with(5) + ->willReturn(['id' => 5, 'name' => 'My Report']); + $this->shareService->expects($this->never()) + ->method('getSharedReport'); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/5'); + + $this->assertInstanceOf(IReference::class, $reference); + $richObject = $reference->getRichObject(); + $this->assertSame(5, $richObject['id']); + $this->assertSame('report', $richObject['item_type']); + $this->assertTrue($richObject['found']); + $this->assertSame('My Report', $richObject['subheader']); + } + + public function testResolveSharedReportFallsBackToShareService(): void { + $this->reportService->expects($this->once()) + ->method('read') + ->with(5) + ->willReturn([]); + $this->shareService->expects($this->once()) + ->method('getSharedReport') + ->with(5) + ->willReturn(['id' => 5, 'name' => 'Shared Report']); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/5'); + + $richObject = $reference->getRichObject(); + $this->assertTrue($richObject['found']); + $this->assertSame('Shared Report', $richObject['subheader']); + } + + public function testResolveMissingReport(): void { + $this->reportService->method('read')->willReturn([]); + $this->shareService->method('getSharedReport')->willReturn([]); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/99'); + + $richObject = $reference->getRichObject(); + $this->assertFalse($richObject['found']); + $this->assertSame('Report not found', $richObject['name']); + } + + public function testResolvePanorama(): void { + // PanoramaService->read() contains the shared-panorama fallback itself + $this->panoramaService->expects($this->once()) + ->method('read') + ->with(7) + ->willReturn(['id' => 7, 'name' => 'My Panorama']); + $this->reportService->expects($this->never()) + ->method('read'); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/pa/7'); + + $richObject = $reference->getRichObject(); + $this->assertSame(7, $richObject['id']); + $this->assertSame('panorama', $richObject['item_type']); + $this->assertTrue($richObject['found']); + $this->assertSame('My Panorama', $richObject['subheader']); + } + + public function testResolveWithoutTrailingIntegerReturnsNotFound(): void { + $this->reportService->expects($this->never())->method('read'); + $this->shareService->expects($this->never())->method('getSharedReport'); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/'); + + $this->assertInstanceOf(IReference::class, $reference); + $richObject = $reference->getRichObject(); + $this->assertSame(0, $richObject['id']); + $this->assertFalse($richObject['found']); + } + + public function testResolveUnmatchedUrlReturnsNull(): void { + $this->assertNull($this->buildProvider()->resolveReference('https://cloud.example.com/apps/files/')); + } +} diff --git a/tests/Service/PanoramaServiceReadTest.php b/tests/Service/PanoramaServiceReadTest.php new file mode 100644 index 000000000..31416a4fa --- /dev/null +++ b/tests/Service/PanoramaServiceReadTest.php @@ -0,0 +1,91 @@ +panoramaMapper = $this->createMock(PanoramaMapper::class); + $this->shareService = $this->createMock(ShareService::class); + } + + private function buildService(): PanoramaService { + return new PanoramaService( + 'testUser', + new FakeL10N(), + new NullLogger(), + $this->createMock(ITagManager::class), + $this->shareService, + $this->panoramaMapper, + $this->createMock(IConfig::class), + $this->createMock(VariableService::class), + $this->createMock(ActivityManager::class) + ); + } + + public function testReadReturnsOwnPanorama(): void { + $own = ['id' => 7, 'name' => 'Own Panorama', 'pages' => '[]']; + $this->panoramaMapper->expects($this->once()) + ->method('readOwn') + ->with(7) + ->willReturn($own); + $this->shareService->expects($this->never()) + ->method('getSharedPanorama'); + + $this->assertSame($own, $this->buildService()->read(7)); + } + + public function testReadFallsBackToSharedPanorama(): void { + $this->panoramaMapper->expects($this->once()) + ->method('readOwn') + ->with(7) + ->willReturn([]); + $this->shareService->expects($this->once()) + ->method('getSharedPanorama') + ->with(7) + ->willReturn([ + 'id' => 7, + 'name' => 'Shared Panorama', + 'pages' => '[]', + 'type' => 99, + 'parent' => 0, + 'user_id' => 'someoneElse', + 'password' => 'secret-hash', + ]); + + $result = $this->buildService()->read(7); + + $this->assertSame(7, $result['id']); + $this->assertSame('Shared Panorama', $result['name']); + $this->assertSame(\OCP\Constants::PERMISSION_READ, $result['permissions']); + // sensitive / internal share fields must not leak + $this->assertArrayNotHasKey('user_id', $result); + $this->assertArrayNotHasKey('password', $result); + } + + public function testReadReturnsEmptyWhenNotAvailable(): void { + $this->panoramaMapper->method('readOwn')->willReturn([]); + $this->shareService->method('getSharedPanorama')->willReturn([]); + + $this->assertSame([], $this->buildService()->read(42)); + } +} diff --git a/tests/Service/ShareServiceSharedPanoramaTest.php b/tests/Service/ShareServiceSharedPanoramaTest.php new file mode 100644 index 000000000..06358c8a5 --- /dev/null +++ b/tests/Service/ShareServiceSharedPanoramaTest.php @@ -0,0 +1,45 @@ +getMockBuilder(ShareService::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSharedItems']) + ->getMock(); + $shareService->method('getSharedItems') + ->with(ShareService::SHARE_ITEM_TYPE_PANORAMA) + ->willReturn($sharedItems); + return $shareService; + } + + public function testGetSharedPanoramaReturnsMatch(): void { + $shareService = $this->buildShareService([ + ['id' => 3, 'name' => 'Other'], + ['id' => 7, 'name' => 'Shared Panorama'], + ]); + + $result = $shareService->getSharedPanorama(7); + + $this->assertSame(7, $result['id']); + $this->assertSame('Shared Panorama', $result['name']); + } + + public function testGetSharedPanoramaReturnsEmptyWhenNotShared(): void { + $shareService = $this->buildShareService([ + ['id' => 3, 'name' => 'Other'], + ]); + + $this->assertSame([], $shareService->getSharedPanorama(99)); + } +} diff --git a/tests/Stubs/OC/Collaboration/Reference/ReferenceManager.php b/tests/Stubs/OC/Collaboration/Reference/ReferenceManager.php new file mode 100644 index 000000000..6db1b974e --- /dev/null +++ b/tests/Stubs/OC/Collaboration/Reference/ReferenceManager.php @@ -0,0 +1,15 @@ + $this->getId(), + 'title' => $this->getTitle(), + 'icon_url' => $this->getIconUrl(), + 'order' => $this->getOrder(), + ]; + } +} diff --git a/tests/Stubs/OCP/Collaboration/Reference/IReference.php b/tests/Stubs/OCP/Collaboration/Reference/IReference.php new file mode 100644 index 000000000..d133c4fda --- /dev/null +++ b/tests/Stubs/OCP/Collaboration/Reference/IReference.php @@ -0,0 +1,12 @@ +reference = $reference; + } + + public function getId(): string { + return $this->reference; + } + + public function setTitle(string $title): void { + $this->title = $title; + } + + public function getTitle(): string { + return $this->title ?? ''; + } + + public function setDescription(?string $description): void { + $this->description = $description; + } + + public function getDescription(): ?string { + return $this->description; + } + + public function setImageUrl(?string $imageUrl): void { + $this->imageUrl = $imageUrl; + } + + public function getImageUrl(): ?string { + return $this->imageUrl; + } + + public function setRichObject(string $type, ?array $richObject): void { + $this->richObjectType = $type; + $this->richObject = $richObject; + } + + public function getRichObjectType(): string { + return $this->richObjectType ?? ''; + } + + public function getRichObject(): array { + return $this->richObject ?? []; + } +} diff --git a/tests/Stubs/OCP/Constants.php b/tests/Stubs/OCP/Constants.php new file mode 100644 index 000000000..5c1fd368d --- /dev/null +++ b/tests/Stubs/OCP/Constants.php @@ -0,0 +1,18 @@ +