class FluidbookStatsController extends Controller
{
- private function _getReporting($fluidbook_id)
- {
- // Get the appropriate server / API token based on the Fluidbook ID
- // Stats are split across different servers depending on the ID:
- // ID < 21210 = stats3.fluidbook.com
- // ID >= 21210 (even numbers) = stats4.fluidbook.com
- // ID >= 21211 (odd numbers) = stats5.fluidbook.com
-
+ public static function getMatomoTokens() {
// Each stats server has a different instance of Matamo, so we need to provide different API tokens for each
// Normally this information would be stored in the .env but there's no good way to do that with an array, so
// it is simpler to keep it here. These are also stored in the shared Bitwarden entry for Matomo.
- $matomo_tokens = [
+ return [
'stats3.fluidbook.com' => '9df722a0bd30878ddc4d737352427502',
'stats4.fluidbook.com' => '3ffdbe052ae625f065573df9fa9515df',
'stats5.fluidbook.com' => '85e9cc307b6e5083249949e9472a80b8',
];
+ }
+
+ private function _getReporting($fluidbook_id)
+ {
+ // Get the appropriate server / API token based on the Fluidbook ID
+ // Stats are split across different servers depending on the ID:
+ // ID < 21210 = stats3.fluidbook.com
+ // ID >= 21210 (even numbers) = stats4.fluidbook.com
+ // ID >= 21211 (odd numbers) = stats5.fluidbook.com
$fluidbook_id = intval($fluidbook_id);
//dump("Server is $server");
+ $matomo_tokens = self::getMatomoTokens();
+
return new Reporting("https://{$server}/", $matomo_tokens[$server]);
}
}
+ protected function API() {
+
+ if (!can('superadmin')) {
+ // Only allow superadmin access because this is a dev tool and it exposes the API tokens
+ return response(null)->setStatusCode('403');
+ }
+
+ $matomo_tokens = json_encode(static::getMatomoTokens());
+
+ return view('fluidbook_stats.API', compact('matomo_tokens'));
+ }
}
"license": "MIT",
"require": {
"php": ">=8.0",
- "ext-json": "*",
- "ext-simplexml": "*",
+ "ext-calendar": "*",
"ext-dom": "*",
+ "ext-json": "*",
"ext-libxml": "*",
+ "ext-simplexml": "*",
"ext-tidy": "*",
"ext-zip": "*",
"ahmadshah/lucy": "dev-master",
+ "chartisan/php": "^1.2",
"cubedesigners/userdatabase": "dev-master",
"cubist/azuretts": "dev-master",
"cubist/cms-back": "dev-master",
+ "cubist/matomo": "dev-master",
"cubist/pdf": "dev-master",
"cubist/scorm": "dev-master",
"fluidbook/tools": "dev-master",
"php-ffmpeg/php-ffmpeg": "^0.18.0",
"phpoffice/phpspreadsheet": "^1.22",
"rustici-software/scormcloud-api-v2-client-php": "^2.0"
-
},
"require-dev": {
"facade/ignition": "^2.17",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "c0e65b576302056a4929e45aaedd1971",
+ "content-hash": "37d4b92043ca0a35270b6f38d7ee16c1",
"packages": [
{
"name": "ahmadshah/lucy",
],
"time": "2020-09-08T20:04:29+00:00"
},
+ {
+ "name": "chartisan/php",
+ "version": "1.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Chartisan/PHP.git",
+ "reference": "85d2352077800e9bcb411aec1ff7e4d23eef93a1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Chartisan/PHP/zipball/85d2352077800e9bcb411aec1ff7e4d23eef93a1",
+ "reference": "85d2352077800e9bcb411aec1ff7e4d23eef93a1",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Chartisan\\PHP\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Èrik Campobadal Forés",
+ "email": "soc@erik.cat"
+ }
+ ],
+ "description": "Chartisan's PHP backend",
+ "support": {
+ "issues": "https://github.com/Chartisan/PHP/issues",
+ "source": "https://github.com/Chartisan/PHP/tree/1.2.1"
+ },
+ "time": "2020-11-05T13:13:58+00:00"
+ },
{
"name": "chrisjean/php-ico",
"version": "1.0.4",
"description": "Cubist Locale",
"time": "2022-04-12T10:03:49+00:00"
},
+ {
+ "name": "cubist/matomo",
+ "version": "dev-master",
+ "source": {
+ "type": "git",
+ "url": "git://git.cubedesigners.com/cubist_matomo.git",
+ "reference": "6fc6532042481409ec54713b2967bea13b20f89d"
+ },
+ "dist": {
+ "type": "tar",
+ "url": "https://composer.cubedesigners.com/dist/cubist/matomo/cubist-matomo-dev-master-7b4016.tar",
+ "reference": "6fc6532042481409ec54713b2967bea13b20f89d",
+ "shasum": "3a9d43421797356f9931b05a37f60da138e10ee3"
+ },
+ "require": {
+ "ext-json": "*",
+ "guzzlehttp/guzzle": "^7.4.1",
+ "php": ">=7.2"
+ },
+ "default-branch": true,
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Cubist\\Matomo\\": "src"
+ }
+ },
+ "license": [
+ "proprietary"
+ ],
+ "authors": [
+ {
+ "name": "Vincent Vanwaelscappel",
+ "email": "vincent@cubedesigners.com"
+ }
+ ],
+ "description": "Matomo API",
+ "time": "2022-06-15T10:09:17+00:00"
+ },
{
"name": "cubist/net",
"version": "dev-master",
--- /dev/null
+{{-- Matomo Stats API Helper --}}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Matomo API Explorer</title>
+ <script src="https://cdn.tailwindcss.com?plugins=forms"></script>
+ <style>
+ html, body {
+ height: 100%;
+ }
+
+ input:not([type=button],[type=submit]), select {
+ padding: 0.5em !important;
+ border-radius: 0.5em !important;
+ line-height: 1 !important;
+ font-size: 0.9em !important;
+ }
+
+ label span {
+ font-weight: 500;
+ color: #fff;
+ }
+
+ </style>
+</head>
+<body class="bg-gray-600 p-4">
+<div x-data="matomo()" class="flex flex-col h-full space-y-2">
+ <!--
+ <select x-model="server">
+ <template x-for="server in Object.keys(servers)" :key="server">
+ <option :value="server" x-text="server"></option>
+ </template>
+ </select>
+ -->
+ <form class="options w-full space-y-2 flex-grow-0" @submit.prevent="loadAPI">
+ <div class="flex items-center space-x-2">
+ <a href="https://developer.matomo.org/api-reference/reporting-api" class="text-yellow-200 hover:text-yellow-300 font-bold" target="_blank">Matomo API</a>
+ <input x-model="generatedURL" class="flex-1 text-xs text-gray-500" @click="$el.select()">
+ </div>
+
+ <div class="flex items-center space-x-2">
+ <label class="space-x-1">
+ <span>Site ID</span>
+ <input x-model="siteID" class="w-20" @focus="$el.select()">
+ </label>
+ <label class="space-x-1">
+ <span>API Method</span>
+ <input class="w-60" type="search" x-model="method" @focus="$el.select()" list="API-methods" autofocus>
+ <datalist id="API-methods">
+ {{-- List of suggestions for the input... --}}
+ <option value="VisitsSummary.get">
+ <option value="VisitsSummary.getVisits">
+ <option value="VisitsSummary.getUniqueVisitors">
+ <option value="Actions.get">
+ <option value="Actions.getPageUrls">
+ <option value="Actions.getSiteSearchKeywords">
+ <option value="Events.getCategory">
+ <option value="UserCountry.getCountry">
+ <option value="UserCountry.getCity">
+ <option value="UserLanguage.getLanguage">
+ </datalist>
+ </label>
+ <label class="space-x-1">
+ <span>Date</span>
+ <input class="w-50" type="search" x-model="date" @focus="$el.select()" list="date-examples">
+ <datalist id="date-examples">
+ {{-- There are lots possible date inputs, so this just to give some ideas --}}
+ <option value="previous12">
+ <option value="last30">
+ <option value="today">
+ <option value="yesterday">
+ <option value="{{ date('Y') }}-01-01,today">
+ </datalist>
+ </label>
+ <label class="space-x-1">
+ <span>Period</span>
+ <select x-model="period" class="capitalize rounded-lg py-0 w-24">
+ <template x-for="p in periods" :key="p">
+ <option :value="p" x-text="p" :selected="period === p"></option>
+ </template>
+ </select>
+ </label>
+ <input type="submit" value="GO" class="bg-green-500 text-white py-1 px-4 rounded" :class="method !== '' || 'cursor-not-allowed opacity-60'">
+ <input type="button" value="Clear" class="bg-red-500 text-white py-1 px-4 rounded" @click="$refs.result.innerHTML = ''">
+ </div>
+ </form>
+
+ <div x-ref="result" class="flex-1 bg-white rounded-lg p-4 overflow-y-scroll"></div>
+
+</div>
+
+<script>
+ function matomo() {
+ return {
+ servers: {!! $matomo_tokens; !!},
+ periods: ['day', 'month', 'year', 'range'],
+ siteID: 16976,
+ method: '',
+ period: 'month',
+ date: '{{ date('Y') }}-01-01,today',
+
+ get server() {
+ if (!this.siteID) return false;
+ let ID = parseInt(this.siteID)
+
+ if (ID < 21210) return 'stats3.fluidbook.com';
+ else if (ID >= 21210 && ID % 2 === 0) return 'stats4.fluidbook.com';
+ else return 'stats5.fluidbook.com';
+
+ },
+
+ get generatedURL() {
+ return `https://${this.server}/?idSite=${this.siteID}&method=${this.method}&date=${this.date}&period=${this.period}&module=API&token_auth=${this.servers[this.server]}&format=JSON`
+ },
+
+ loadAPI() {
+ let $this = this;
+
+ if (this.method === '') return false;
+
+ fetch(this.generatedURL)
+ .then(response => response.json())
+ .then((data) => {
+ //$this.result = JSON.stringify(data, null, 2)
+ // let filteredData = Object.entries(data).filter(([key, value]) => !Array.isArray(value));
+ // Filter out empty results from the API...
+ let filteredData = Object.fromEntries(Object.entries(data).filter(([key, value]) => !Array.isArray(value) || (Array.isArray(value) && value.length > 0)));
+ // window.formatter = new JSONFormatter(filteredData, Infinity, { animateClose: false });
+ // $this.$refs.result.insertAdjacentHTML('afterbegin', window.formatter.render().innerHTML + '<br><hr><br>');
+
+ console.log("data:", data);
+ console.log("filtered:", filteredData);
+ // return false;
+
+
+ if ($this.period === 'range') { // When doing a range, data structure returned isn't the same as others
+ filteredData = { [$this.date]: filteredData }
+ }
+
+ let requestDetails = `<h2 class="mb-4 font-medium text-sm">
+ Results for <span class="text-blue-600">${$this.method}</span>
+ (date: <span class="text-blue-600">${$this.date}</span>,
+ period: <span class="text-blue-600">${$this.period}</span>)
+ <a class="inline-block px-2 py-0.5 bg-blue-500 text-white text-xs ml-2 rounded" href="${$this.generatedURL}" target="_blank">RAW ↗️</a>
+ </h2>`;
+
+ let result = '';
+
+ // Check first item of filteredData object to see if it contains a nested array of data
+ if (Array.isArray(Object.values(filteredData)[0])) {
+ for (const [date, nestedData] of Object.entries(filteredData)) {
+ console.log(date, nestedData)
+ result += `<h3 class="bg-gray-700 font-bold mt-4 mb-2 rounded px-3 py-1 text-white text-sm w-max">${date}</h3>`;
+ result += $this.tableFromData(nestedData, true);
+ }
+ } else {
+ result += $this.tableFromData(filteredData);
+ }
+
+ $this.$refs.result.insertAdjacentHTML('afterbegin', requestDetails + result + '<hr class="my-4">')
+
+ });
+ },
+
+ tableFromData(filteredData, hideDateColumn) {
+ let tableClasses = 'border-collapse border border-slate-400 p-2 text-xs';
+ let table = `<table class="${tableClasses}">`;
+ let firstRow = Object.values(filteredData)[0];
+
+ let headings = (typeof firstRow === 'object') ?
+ Object.keys(firstRow).map(heading => `<th class="${tableClasses}">${heading}</th>`).join("")
+ :
+ `<th class="${tableClasses}"> </th>`; // Only a singular value returned by API
+
+ table += `<tr class="bg-gray-400">`;
+
+ if (!hideDateColumn) {
+ table += `<th class="${tableClasses}">Date</th>`
+ }
+
+ table += headings;
+ table += `</tr>`;
+
+ Object.entries(filteredData).forEach(function(datedSet, index) {
+
+ // console.log('datedSet...', datedSet);
+
+ let date = datedSet[0];
+ let dateData = (typeof datedSet[1] === 'object') ? Object.values(datedSet[1]) : datedSet[1];
+
+ table += `<tr class="${ index % 2 === 1 ? 'bg-gray-200' : '' }">`;
+
+ if (!hideDateColumn) {
+ table += `<td class="${tableClasses}">${date}</td>`;
+ }
+
+ if (typeof dateData === 'object') {
+ table += dateData.map(value => `<td class="${tableClasses}">${value}</td>`).join("");
+ } else {
+ table += `<td class="${tableClasses}">${dateData}</td>`;
+ }
+
+ table += `</tr>`;
+
+ // dateData.forEach(function(data) {
+ // console.log('... data', data);
+ // table += `<tr>`;
+ // table += `<td>${date}</td>`;
+ // table += data.map(value => `<td>${value}</td>`).join("");
+ // table += `</tr>`;
+ // })
+ });
+
+ table += '</table>';
+
+ return table;
+ }
+
+ }
+ }
+</script>
+
+<script src="//unpkg.com/alpinejs" defer></script>
+{{--<script src="https://unpkg.com/json-formatter-js"></script>--}}
+</body>
+</html>
--- /dev/null
+@extends(backpack_view('blank'))
+
+@section('content')
+ <h1>{{ __('Statistiques') }}</h1>
+
+ <div class="chart-wrapper" style="position:relative; height: 30vh;">
+ <div id="chart"></div>
+ </div>
+
+ @dump($fluidbook, $visits, $pageviews, $searches)
+
+@endsection
+
+@push('after_scripts')
+ {{-- Charting library --}}
+ <script src="https://unpkg.com/chart.js@^2.9.3/dist/Chart.min.js"></script>
+ {{-- Chartisan --}}
+ <script src="https://unpkg.com/@chartisan/chartjs@^2.1.0/dist/chartisan_chartjs.umd.js"></script>
+
+ <script>
+ const bar_offset = 2; // How much to offset each overlapping bar (applied as a -/+ depending on which dataset)
+ const chart = new Chartisan({
+ el: '#chart',
+ data: {!! $chart !!},
+ hooks: new ChartisanHooks()
+ .colors(['#9ec400', '#888'])
+ .tooltip({
+ mode: 'index',
+ position: 'nearest',
+ callbacks: {
+ title: function(context) {
+ return chart.options.data.chart.extra.tooltip_labels[context[0].label];
+ }
+ },
+ })
+ .datasets([{
+ barPercentage: 0.9,
+ // categoryPercentage: 0.8,
+ // maxBarThickness: 50,
+ }])
+ // Complex options (ref: https://github.com/Chartisan/Chartisan/issues/7#issuecomment-774745067)
+ .custom(function({ data, merge, server }) {
+ // data -> Contains the current chart configuration
+ // data that will be passed to the chart instance.
+ // merge -> Contains a function that can be called to merge
+ // two javascript objects and returns its merge.
+ // server -> Contains the server information in case you need
+ // to access the raw information provided by the server.
+ // This is mostly used to access the `extra` field.
+
+
+ //console.log('data', data, 'server', server);
+
+ let merged = merge(data, {
+ options: {
+ maintainAspectRatio: false,
+ // responsive: true,
+ scales: {
+ xAxes: [{ stacked: true }],
+ yAxes: [{ stacked: false }], // Don't stack y-axis: prevents datasets from adding
+ },
+ },
+ plugins: [{
+ afterUpdate: function(chart) {
+ // Create an offset between the bars
+ // Ref: https://stackoverflow.com/questions/62731182/chart-js-creating-a-barchart-with-overlaying-and-offset-bars
+ chart.config.data.datasets.forEach(function(dataset, index) {
+ // First dataset is moved left (-), second is moved right (+)
+ let offset = index % 2 === 0 ? 0 - bar_offset : bar_offset;
+
+ dataset._meta[0].data.forEach(function(entry) {
+ let model = entry._model;
+ model.x += offset;
+ model.controlPointNextX = offset;
+ model.controlPointPreviousX = offset;
+ });
+ });
+ }
+ }]
+ });
+
+ //console.log("MERGED!", merged)
+
+ return merged;
+
+ // The function must always return the new chart configuration.
+ })
+ })
+ </script>
+
+@endpush
'namespace' => 'App\Http\Controllers\Admin',
], function () { // custom admin routes
try {
+ Route::crud('company', 'CompanyCrudController');
+ Route::crud('fluidbook-publication', 'FluidbookPublicationCrudController');
+ Route::crud('fluidbook-quote', 'FluidbookQuoteCrudController');
+ Route::crud('fluidbook-theme', 'FluidbookThemeCrudController');
Route::crud('locale', 'LocaleCrudController');
Route::crud('settings', 'SettingsCrudController');
- Route::crud('quiztranslation', 'QuiztranslationCrudController');
- Route::crud('fluidbook-theme', 'FluidbookThemeCrudController');
- Route::crud('fluidbook-quote', 'FluidbookQuoteCrudController');
- Route::crud('quizatttempt', 'QuizatttemptCrudController');
+ Route::crud('signature', 'SignatureCrudController');
Route::crud('toolbox-translate', 'ToolboxTranslateCrudController');
- Route::crud('fluidbook-publication', 'FluidbookPublicationCrudController');
Route::crud('quiz', 'QuizCrudController');
- Route::crud('signature', 'SignatureCrudController');
- Route::crud('users', 'UsersCrudController');
- Route::crud('company', 'CompanyCrudController');
+ Route::crud('quizatttempt', 'QuizatttemptCrudController');
+ Route::crud('quiztranslation', 'QuiztranslationCrudController');
Route::crud('users', 'UsersCrudController');
} catch (\Throwable $e) {
Route::get('fluidbookthemepreview/{id}-burger.jpg', 'FluidbookThemePreviewController@previewBurger');
Route::get('fluidbookthemepreview/{id}-menu.jpg', 'FluidbookThemePreviewController@previewMenu');
Route::get('fluidbookthemepreview/{id}.jpg', 'FluidbookThemePreviewController@preview');
-
+ Route::get('fluidbook-stats/API', 'FluidbookStatsController@API');
+ Route::get('fluidbook-stats/{fluidbook_id}/{date?}', 'FluidbookStatsController@summary');
});