domma-cms 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/plugins/analytics/admin/templates/analytics.html +52 -1
- package/plugins/analytics/admin/views/analytics.js +157 -32
- package/plugins/analytics/config.js +10 -2
- package/plugins/analytics/plugin.js +214 -25
- package/plugins/analytics/plugin.json +9 -5
- package/plugins/analytics/public/inject-body.html +25 -7
- package/plugins/blog/admin/templates/blog.html +25 -2
- package/plugins/blog/admin/views/blog.js +72 -56
- package/plugins/blog/admin/views/post-editor.js +98 -79
- package/plugins/blog/plugin.js +133 -0
- package/plugins/blog/plugin.json +3 -3
- package/plugins/blog/templates/post.html +2 -1
- package/plugins/invoice/admin/templates/editor.html +129 -0
- package/plugins/invoice/admin/templates/index.html +43 -0
- package/plugins/invoice/admin/templates/issuers.html +5 -0
- package/plugins/invoice/admin/templates/receivers.html +5 -0
- package/plugins/invoice/admin/views/editor.js +267 -0
- package/plugins/invoice/admin/views/index.js +155 -0
- package/plugins/invoice/admin/views/issuers.js +23 -0
- package/plugins/invoice/admin/views/party-view.js +148 -0
- package/plugins/invoice/admin/views/receivers.js +22 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
- package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
- package/plugins/invoice/collections/invoices/schema.json +27 -0
- package/plugins/invoice/config.js +16 -0
- package/plugins/invoice/plugin.js +283 -0
- package/plugins/invoice/plugin.json +85 -0
- package/plugins/invoice/templates/invoice-print.html +213 -0
- package/server/services/markdown.js +24 -4
- package/server/services/renderer.js +9 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domma-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server/server.js",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"@fastify/rate-limit": "^10.3.0",
|
|
74
74
|
"@fastify/static": "9.1.1",
|
|
75
75
|
"bcryptjs": "^3.0.3",
|
|
76
|
-
"domma-js": "^0.
|
|
76
|
+
"domma-js": "^0.24.0",
|
|
77
77
|
"dotenv": "^17.2.3",
|
|
78
78
|
"fastify": "5.8.5",
|
|
79
79
|
"gray-matter": "^4.0.3",
|
|
@@ -1,9 +1,60 @@
|
|
|
1
1
|
<div class="view-header">
|
|
2
2
|
<h1><span data-icon="chart-bar"></span> Analytics</h1>
|
|
3
|
-
<
|
|
3
|
+
<div class="d-flex gap-2">
|
|
4
|
+
<button id="export-btn" class="btn btn-ghost btn-sm" type="button">
|
|
5
|
+
<span data-icon="download"></span> Export CSV
|
|
6
|
+
</button>
|
|
7
|
+
<button id="reset-btn" class="btn btn-ghost btn-sm">
|
|
8
|
+
<span data-icon="trash"></span> Reset stats
|
|
9
|
+
</button>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div class="card mb-4">
|
|
14
|
+
<div class="card-body">
|
|
15
|
+
<div id="range-tabs" class="d-flex gap-2 flex-wrap">
|
|
16
|
+
<button class="btn btn-ghost btn-sm range-btn" data-range="today">Today</button>
|
|
17
|
+
<button class="btn btn-ghost btn-sm range-btn active" data-range="7d">Last 7 days</button>
|
|
18
|
+
<button class="btn btn-ghost btn-sm range-btn" data-range="30d">Last 30 days</button>
|
|
19
|
+
<button class="btn btn-ghost btn-sm range-btn" data-range="90d">Last 90 days</button>
|
|
20
|
+
<button class="btn btn-ghost btn-sm range-btn" data-range="all">All time</button>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="grid grid-cols-3 gap-4 mb-4">
|
|
26
|
+
<div class="card">
|
|
27
|
+
<div class="card-body">
|
|
28
|
+
<div class="text-muted text-sm">Hits in range</div>
|
|
29
|
+
<div class="h2 mb-0" id="kpi-range">—</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="card">
|
|
33
|
+
<div class="card-body">
|
|
34
|
+
<div class="text-muted text-sm">Unique pages</div>
|
|
35
|
+
<div class="h2 mb-0" id="kpi-pages">—</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="card">
|
|
39
|
+
<div class="card-body">
|
|
40
|
+
<div class="text-muted text-sm">Lifetime hits</div>
|
|
41
|
+
<div class="h2 mb-0" id="kpi-lifetime">—</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="card mb-4">
|
|
47
|
+
<div class="card-header"><strong>Hits over time</strong></div>
|
|
48
|
+
<div class="card-body">
|
|
49
|
+
<canvas id="trend-chart" height="90"></canvas>
|
|
50
|
+
</div>
|
|
4
51
|
</div>
|
|
5
52
|
|
|
6
53
|
<div class="card">
|
|
54
|
+
<div class="card-header d-flex justify-content-between align-items-center">
|
|
55
|
+
<strong>Top pages</strong>
|
|
56
|
+
<input id="filter-input" type="search" class="form-control form-control-sm" placeholder="Filter URL…" style="max-width: 240px;">
|
|
57
|
+
</div>
|
|
7
58
|
<div class="card-body">
|
|
8
59
|
<div id="analytics-table"></div>
|
|
9
60
|
</div>
|
|
@@ -1,51 +1,176 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Analytics Plugin — Admin View
|
|
3
|
-
*
|
|
4
|
-
* Loaded dynamically from /plugins/ static path.
|
|
3
|
+
* KPI cards, trend chart (Chart.js via CDN), filterable top-pages table, CSV export.
|
|
5
4
|
*/
|
|
6
|
-
|
|
7
|
-
templateUrl: '/plugins/example-analytics/admin/templates/analytics.html',
|
|
5
|
+
const CHART_CDN = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js';
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
let chartInstance = null;
|
|
8
|
+
let currentRange = '7d';
|
|
9
|
+
let currentTop = [];
|
|
10
|
+
let filterTerm = '';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (!confirmed) return;
|
|
15
|
-
try {
|
|
16
|
-
await fetch('/api/plugins/example-analytics/stats', {
|
|
17
|
-
method: 'DELETE',
|
|
18
|
-
headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}
|
|
19
|
-
});
|
|
20
|
-
E.toast('Analytics reset.', { type: 'success' });
|
|
21
|
-
await loadStats($container);
|
|
22
|
-
} catch {
|
|
23
|
-
E.toast('Reset failed.', { type: 'error' });
|
|
24
|
-
}
|
|
25
|
-
});
|
|
12
|
+
export const analyticsView = {
|
|
13
|
+
templateUrl: '/plugins/analytics/admin/templates/analytics.html',
|
|
26
14
|
|
|
15
|
+
async onMount($container) {
|
|
16
|
+
await ensureChartJs();
|
|
17
|
+
await refresh($container, currentRange);
|
|
18
|
+
wireRangeTabs($container);
|
|
19
|
+
wireExport($container);
|
|
20
|
+
wireReset($container);
|
|
21
|
+
wireFilter($container);
|
|
27
22
|
Domma.icons.scan();
|
|
28
23
|
}
|
|
29
24
|
};
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
function authHeaders() {
|
|
27
|
+
return { 'Authorization': 'Bearer ' + (S.get('auth_token') || '') };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureChartJs() {
|
|
31
|
+
if (window.Chart) return Promise.resolve();
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const s = document.createElement('script');
|
|
34
|
+
s.src = CHART_CDN;
|
|
35
|
+
s.async = true;
|
|
36
|
+
s.onload = () => resolve();
|
|
37
|
+
s.onerror = () => reject(new Error('chart.js load failed'));
|
|
38
|
+
document.head.appendChild(s);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function refresh($container, range) {
|
|
43
|
+
currentRange = range;
|
|
33
44
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
const [stats, series] = await Promise.all([
|
|
46
|
+
fetch('/api/plugins/analytics/stats?range=' + encodeURIComponent(range), { headers: authHeaders() })
|
|
47
|
+
.then((r) => r.json()),
|
|
48
|
+
fetch('/api/plugins/analytics/series?range=' + encodeURIComponent(range === 'all' ? '30d' : range), { headers: authHeaders() })
|
|
49
|
+
.then((r) => r.json())
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
$container.find('#kpi-range').text(stats.total.toLocaleString());
|
|
53
|
+
$container.find('#kpi-pages').text(stats.uniquePages.toLocaleString());
|
|
54
|
+
$container.find('#kpi-lifetime').text(stats.lifetimeTotal.toLocaleString());
|
|
55
|
+
|
|
56
|
+
currentTop = stats.top || [];
|
|
57
|
+
renderTable($container);
|
|
58
|
+
renderChart($container, series.series || []);
|
|
59
|
+
updateExportLink($container);
|
|
60
|
+
} catch (err) {
|
|
40
61
|
E.toast('Could not load analytics data.', { type: 'error' });
|
|
41
62
|
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderTable($container) {
|
|
66
|
+
const filtered = filterTerm
|
|
67
|
+
? currentTop.filter((row) => row.url.toLowerCase().includes(filterTerm))
|
|
68
|
+
: currentTop;
|
|
42
69
|
|
|
43
70
|
T.create('#analytics-table', {
|
|
44
|
-
data:
|
|
71
|
+
data: filtered,
|
|
45
72
|
columns: [
|
|
46
|
-
|
|
47
|
-
|
|
73
|
+
{ key: 'url', title: 'Page URL', render: (val) => `<code>${val}</code>` },
|
|
74
|
+
{ key: 'hits', title: 'Page views' }
|
|
48
75
|
],
|
|
49
|
-
emptyMessage: 'No page views recorded yet.'
|
|
76
|
+
emptyMessage: filterTerm ? 'No pages match that filter.' : 'No page views recorded yet.'
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderChart($container, series) {
|
|
81
|
+
const canvas = $container.find('#trend-chart').get(0);
|
|
82
|
+
if (!canvas || !window.Chart) return;
|
|
83
|
+
if (chartInstance) {
|
|
84
|
+
chartInstance.destroy();
|
|
85
|
+
chartInstance = null;
|
|
86
|
+
}
|
|
87
|
+
chartInstance = new window.Chart(canvas, {
|
|
88
|
+
type: 'line',
|
|
89
|
+
data: {
|
|
90
|
+
labels: series.map((p) => p.date),
|
|
91
|
+
datasets: [{
|
|
92
|
+
label: 'Hits',
|
|
93
|
+
data: series.map((p) => p.hits),
|
|
94
|
+
borderColor: 'rgba(99, 102, 241, 1)',
|
|
95
|
+
backgroundColor: 'rgba(99, 102, 241, 0.15)',
|
|
96
|
+
fill: true,
|
|
97
|
+
tension: 0.3,
|
|
98
|
+
borderWidth: 2,
|
|
99
|
+
pointRadius: 2
|
|
100
|
+
}]
|
|
101
|
+
},
|
|
102
|
+
options: {
|
|
103
|
+
responsive: true,
|
|
104
|
+
maintainAspectRatio: false,
|
|
105
|
+
plugins: { legend: { display: false } },
|
|
106
|
+
scales: {
|
|
107
|
+
y: { beginAtZero: true, ticks: { precision: 0 } },
|
|
108
|
+
x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function wireRangeTabs($container) {
|
|
115
|
+
$container.find('.range-btn').on('click', function () {
|
|
116
|
+
const btn = $(this);
|
|
117
|
+
$container.find('.range-btn').removeClass('active');
|
|
118
|
+
btn.addClass('active');
|
|
119
|
+
refresh($container, btn.attr('data-range'));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function wireExport($container) {
|
|
124
|
+
$container.find('#export-btn').on('click', async () => {
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetch('/api/plugins/analytics/export.csv?range=' + encodeURIComponent(currentRange), {
|
|
127
|
+
headers: authHeaders()
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) throw new Error('export failed');
|
|
130
|
+
const blob = await res.blob();
|
|
131
|
+
const cd = res.headers.get('Content-Disposition') || '';
|
|
132
|
+
const match = cd.match(/filename="?([^"]+)"?/);
|
|
133
|
+
const filename = match ? match[1] : 'analytics.csv';
|
|
134
|
+
const url = URL.createObjectURL(blob);
|
|
135
|
+
const a = document.createElement('a');
|
|
136
|
+
a.href = url;
|
|
137
|
+
a.download = filename;
|
|
138
|
+
document.body.appendChild(a);
|
|
139
|
+
a.click();
|
|
140
|
+
a.remove();
|
|
141
|
+
URL.revokeObjectURL(url);
|
|
142
|
+
} catch {
|
|
143
|
+
E.toast('Export failed.', { type: 'error' });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function updateExportLink() {
|
|
149
|
+
// no-op; export button uses fetch+blob to preserve Bearer auth
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function wireReset($container) {
|
|
153
|
+
$container.find('#reset-btn').on('click', async () => {
|
|
154
|
+
const confirmed = await E.confirm('Reset all analytics data? This cannot be undone.');
|
|
155
|
+
if (!confirmed) return;
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetch('/api/plugins/analytics/stats', {
|
|
158
|
+
method: 'DELETE',
|
|
159
|
+
headers: authHeaders()
|
|
160
|
+
});
|
|
161
|
+
if (!res.ok) throw new Error('reset failed');
|
|
162
|
+
E.toast('Analytics reset.', { type: 'success' });
|
|
163
|
+
await refresh($container, currentRange);
|
|
164
|
+
} catch {
|
|
165
|
+
E.toast('Reset failed.', { type: 'error' });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function wireFilter($container) {
|
|
171
|
+
const input = $container.find('#filter-input');
|
|
172
|
+
input.on('input', () => {
|
|
173
|
+
filterTerm = (input.val() || '').toString().toLowerCase().trim();
|
|
174
|
+
renderTable($container);
|
|
50
175
|
});
|
|
51
176
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Analytics Plugin — Default Configuration
|
|
3
|
+
*
|
|
4
|
+
* excludeAdmin strip /admin pageviews before recording
|
|
5
|
+
* excludePaths array of path prefixes to ignore (e.g. ['/api', '/healthz'])
|
|
6
|
+
* honourDnt respect navigator.doNotTrack on the client
|
|
7
|
+
* sessionDedup only record one hit per URL per browser session
|
|
3
8
|
*/
|
|
4
9
|
export default {
|
|
5
|
-
excludeAdmin: true
|
|
10
|
+
excludeAdmin: true,
|
|
11
|
+
excludePaths: ['/api'],
|
|
12
|
+
honourDnt: true,
|
|
13
|
+
sessionDedup: true
|
|
6
14
|
};
|
|
@@ -1,58 +1,247 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Analytics Plugin — Server
|
|
3
|
-
* Tracks page hits
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Tracks page hits with per-day buckets so we can chart trends and
|
|
4
|
+
* filter by time range.
|
|
5
|
+
*
|
|
6
|
+
* Storage:
|
|
7
|
+
* lifetime.json { "/url": totalHits }
|
|
8
|
+
* daily.json { "YYYY-MM-DD": { "/url": hits } }
|
|
9
|
+
*
|
|
10
|
+
* Endpoints (mounted at /api/plugins/analytics):
|
|
11
|
+
* POST /hit public record a hit { url, dnt }
|
|
12
|
+
* GET /stats admin summary + top pages (?range=today|7d|30d|90d|all|custom&from=&to=)
|
|
13
|
+
* GET /series admin per-day totals series (?days=N or ?from=&to=)
|
|
14
|
+
* GET /export.csv admin CSV export of top pages for the active range
|
|
15
|
+
* DELETE /stats admin reset all stats
|
|
8
16
|
*/
|
|
9
17
|
import fs from 'fs/promises';
|
|
10
18
|
import path from 'path';
|
|
11
19
|
import {fileURLToPath} from 'url';
|
|
12
20
|
|
|
13
21
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const
|
|
22
|
+
const LIFETIME_FILE = path.join(__dirname, 'lifetime.json');
|
|
23
|
+
const DAILY_FILE = path.join(__dirname, 'daily.json');
|
|
24
|
+
const LEGACY_FILE = path.join(__dirname, 'stats.json');
|
|
15
25
|
|
|
16
|
-
async function
|
|
26
|
+
async function readJson(file, fallback) {
|
|
17
27
|
try {
|
|
18
|
-
return JSON.parse(await fs.readFile(
|
|
28
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
19
29
|
} catch {
|
|
20
|
-
return
|
|
30
|
+
return fallback;
|
|
21
31
|
}
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
async function
|
|
25
|
-
|
|
34
|
+
async function writeJson(file, data) {
|
|
35
|
+
const tmp = file + '.tmp';
|
|
36
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
37
|
+
await fs.rename(tmp, file);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One-shot migration from the legacy flat stats.json.
|
|
42
|
+
* Preserves existing totals as lifetime numbers; daily history starts empty.
|
|
43
|
+
*/
|
|
44
|
+
async function migrateLegacy() {
|
|
45
|
+
try {
|
|
46
|
+
await fs.access(LIFETIME_FILE);
|
|
47
|
+
return; // already migrated
|
|
48
|
+
} catch {
|
|
49
|
+
// fall through
|
|
50
|
+
}
|
|
51
|
+
const legacy = await readJson(LEGACY_FILE, null);
|
|
52
|
+
if (legacy && typeof legacy === 'object') {
|
|
53
|
+
await writeJson(LIFETIME_FILE, legacy);
|
|
54
|
+
await writeJson(DAILY_FILE, {});
|
|
55
|
+
// keep stats.json on disk as a backup; the next reset will wipe it
|
|
56
|
+
} else {
|
|
57
|
+
await writeJson(LIFETIME_FILE, {});
|
|
58
|
+
await writeJson(DAILY_FILE, {});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function todayKey(date = new Date()) {
|
|
63
|
+
return date.toISOString().slice(0, 10);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rangeToDates(range, from, to) {
|
|
67
|
+
const today = new Date();
|
|
68
|
+
today.setUTCHours(0, 0, 0, 0);
|
|
69
|
+
const end = to ? new Date(to + 'T00:00:00Z') : today;
|
|
70
|
+
let start;
|
|
71
|
+
switch (range) {
|
|
72
|
+
case 'today':
|
|
73
|
+
start = today;
|
|
74
|
+
break;
|
|
75
|
+
case '7d':
|
|
76
|
+
start = new Date(today);
|
|
77
|
+
start.setUTCDate(start.getUTCDate() - 6);
|
|
78
|
+
break;
|
|
79
|
+
case '30d':
|
|
80
|
+
start = new Date(today);
|
|
81
|
+
start.setUTCDate(start.getUTCDate() - 29);
|
|
82
|
+
break;
|
|
83
|
+
case '90d':
|
|
84
|
+
start = new Date(today);
|
|
85
|
+
start.setUTCDate(start.getUTCDate() - 89);
|
|
86
|
+
break;
|
|
87
|
+
case 'custom':
|
|
88
|
+
start = from ? new Date(from + 'T00:00:00Z') : today;
|
|
89
|
+
break;
|
|
90
|
+
case 'all':
|
|
91
|
+
default:
|
|
92
|
+
return null; // all-time → use lifetime aggregate
|
|
93
|
+
}
|
|
94
|
+
return { start, end };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function aggregateDaily(daily, start, end) {
|
|
98
|
+
const totals = {};
|
|
99
|
+
let total = 0;
|
|
100
|
+
const startMs = start.getTime();
|
|
101
|
+
const endMs = end.getTime();
|
|
102
|
+
for (const [day, urls] of Object.entries(daily)) {
|
|
103
|
+
const dayMs = new Date(day + 'T00:00:00Z').getTime();
|
|
104
|
+
if (dayMs < startMs || dayMs > endMs) continue;
|
|
105
|
+
for (const [url, hits] of Object.entries(urls)) {
|
|
106
|
+
totals[url] = (totals[url] || 0) + hits;
|
|
107
|
+
total += hits;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { totals, total };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildSeries(daily, start, end) {
|
|
114
|
+
const series = [];
|
|
115
|
+
const cursor = new Date(start);
|
|
116
|
+
while (cursor.getTime() <= end.getTime()) {
|
|
117
|
+
const key = todayKey(cursor);
|
|
118
|
+
const day = daily[key] || {};
|
|
119
|
+
const hits = Object.values(day).reduce((acc, n) => acc + n, 0);
|
|
120
|
+
series.push({ date: key, hits });
|
|
121
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
122
|
+
}
|
|
123
|
+
return series;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function escapeCsvCell(value) {
|
|
127
|
+
const str = String(value ?? '');
|
|
128
|
+
if (/[",\n\r]/.test(str)) {
|
|
129
|
+
return '"' + str.replace(/"/g, '""') + '"';
|
|
130
|
+
}
|
|
131
|
+
return str;
|
|
26
132
|
}
|
|
27
133
|
|
|
28
134
|
export default async function analyticsPlugin(fastify, options) {
|
|
29
|
-
|
|
135
|
+
const { authenticate, requireAdmin } = options.auth;
|
|
136
|
+
const settings = options.settings || {};
|
|
137
|
+
|
|
138
|
+
await migrateLegacy();
|
|
30
139
|
|
|
31
|
-
// Record a page hit — called by the client-side injection script (public)
|
|
32
140
|
fastify.post('/hit', async (request, reply) => {
|
|
33
|
-
const { url } = request.body || {};
|
|
141
|
+
const { url, dnt } = request.body || {};
|
|
34
142
|
if (!url || typeof url !== 'string') {
|
|
35
143
|
return reply.status(400).send({ error: 'url is required' });
|
|
36
144
|
}
|
|
145
|
+
if (dnt === true || dnt === '1') {
|
|
146
|
+
return { ok: true, ignored: 'dnt' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const normalised = url.split('?')[0].split('#')[0].replace(/\/+$/, '') || '/';
|
|
150
|
+
if (settings.excludeAdmin && normalised.startsWith('/admin')) {
|
|
151
|
+
return { ok: true, ignored: 'admin' };
|
|
152
|
+
}
|
|
153
|
+
if (Array.isArray(settings.excludePaths)) {
|
|
154
|
+
for (const prefix of settings.excludePaths) {
|
|
155
|
+
if (typeof prefix === 'string' && prefix && normalised.startsWith(prefix)) {
|
|
156
|
+
return { ok: true, ignored: 'excluded' };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
37
160
|
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
161
|
+
const today = todayKey();
|
|
162
|
+
const [lifetime, daily] = await Promise.all([
|
|
163
|
+
readJson(LIFETIME_FILE, {}),
|
|
164
|
+
readJson(DAILY_FILE, {})
|
|
165
|
+
]);
|
|
166
|
+
lifetime[normalised] = (lifetime[normalised] || 0) + 1;
|
|
167
|
+
if (!daily[today]) daily[today] = {};
|
|
168
|
+
daily[today][normalised] = (daily[today][normalised] || 0) + 1;
|
|
169
|
+
await Promise.all([writeJson(LIFETIME_FILE, lifetime), writeJson(DAILY_FILE, daily)]);
|
|
42
170
|
return { ok: true };
|
|
43
171
|
});
|
|
44
172
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
173
|
+
fastify.get('/stats', { preHandler: [authenticate, requireAdmin] }, async (request) => {
|
|
174
|
+
const { range = '30d', from, to } = request.query || {};
|
|
175
|
+
const dates = rangeToDates(range, from, to);
|
|
176
|
+
const [lifetime, daily] = await Promise.all([
|
|
177
|
+
readJson(LIFETIME_FILE, {}),
|
|
178
|
+
readJson(DAILY_FILE, {})
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
let totals;
|
|
182
|
+
let total;
|
|
183
|
+
if (!dates) {
|
|
184
|
+
totals = lifetime;
|
|
185
|
+
total = Object.values(lifetime).reduce((acc, n) => acc + n, 0);
|
|
186
|
+
} else {
|
|
187
|
+
const agg = aggregateDaily(daily, dates.start, dates.end);
|
|
188
|
+
totals = agg.totals;
|
|
189
|
+
total = agg.total;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const top = Object.entries(totals)
|
|
193
|
+
.map(([url, hits]) => ({ url, hits }))
|
|
194
|
+
.sort((a, b) => b.hits - a.hits);
|
|
195
|
+
|
|
196
|
+
const lifetimeTotal = Object.values(lifetime).reduce((acc, n) => acc + n, 0);
|
|
197
|
+
return {
|
|
198
|
+
range,
|
|
199
|
+
from: dates ? todayKey(dates.start) : null,
|
|
200
|
+
to: dates ? todayKey(dates.end) : null,
|
|
201
|
+
total,
|
|
202
|
+
lifetimeTotal,
|
|
203
|
+
uniquePages: top.length,
|
|
204
|
+
top
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
fastify.get('/series', { preHandler: [authenticate, requireAdmin] }, async (request) => {
|
|
209
|
+
const { range = '30d', from, to } = request.query || {};
|
|
210
|
+
const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
|
|
211
|
+
const daily = await readJson(DAILY_FILE, {});
|
|
212
|
+
return { series: buildSeries(daily, dates.start, dates.end) };
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
fastify.get('/export.csv', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
|
216
|
+
const { range = '30d', from, to } = request.query || {};
|
|
217
|
+
const dates = rangeToDates(range, from, to);
|
|
218
|
+
const [lifetime, daily] = await Promise.all([
|
|
219
|
+
readJson(LIFETIME_FILE, {}),
|
|
220
|
+
readJson(DAILY_FILE, {})
|
|
221
|
+
]);
|
|
222
|
+
const totals = dates ? aggregateDaily(daily, dates.start, dates.end).totals : lifetime;
|
|
223
|
+
const rows = Object.entries(totals)
|
|
49
224
|
.map(([url, hits]) => ({ url, hits }))
|
|
50
225
|
.sort((a, b) => b.hits - a.hits);
|
|
226
|
+
|
|
227
|
+
const lines = ['url,hits'];
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
lines.push(`${escapeCsvCell(row.url)},${row.hits}`);
|
|
230
|
+
}
|
|
231
|
+
const filename = `analytics-${range}-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
232
|
+
reply
|
|
233
|
+
.header('Content-Type', 'text/csv; charset=utf-8')
|
|
234
|
+
.header('Content-Disposition', `attachment; filename="${filename}"`);
|
|
235
|
+
return lines.join('\n') + '\n';
|
|
51
236
|
});
|
|
52
237
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
238
|
+
fastify.delete('/stats', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
239
|
+
await Promise.all([writeJson(LIFETIME_FILE, {}), writeJson(DAILY_FILE, {})]);
|
|
240
|
+
try {
|
|
241
|
+
await fs.unlink(LEGACY_FILE);
|
|
242
|
+
} catch {
|
|
243
|
+
// ignore
|
|
244
|
+
}
|
|
56
245
|
return { ok: true };
|
|
57
246
|
});
|
|
58
247
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "analytics",
|
|
3
3
|
"displayName": "Analytics",
|
|
4
|
-
"version": "
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
|
+
"description": "Page view analytics with daily trends, time-range filtering, top-pages table and CSV export. Honours Do-Not-Track and dedups per session.",
|
|
6
6
|
"author": "Darryl Waterhouse",
|
|
7
|
-
"date": "2026-
|
|
7
|
+
"date": "2026-05-04",
|
|
8
8
|
"icon": "chart-bar",
|
|
9
9
|
"admin": {
|
|
10
10
|
"sidebar": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
"views": {
|
|
27
27
|
"plugin-analytics": {
|
|
28
|
-
"entry": "analytics/admin/views/analytics.js?v=
|
|
28
|
+
"entry": "analytics/admin/views/analytics.js?v=2c26480d",
|
|
29
29
|
"exportName": "analyticsView"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -37,7 +37,11 @@
|
|
|
37
37
|
"scaffold": {
|
|
38
38
|
"reset": [
|
|
39
39
|
{
|
|
40
|
-
"path": "
|
|
40
|
+
"path": "lifetime.json",
|
|
41
|
+
"content": "{}"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"path": "daily.json",
|
|
41
45
|
"content": "{}"
|
|
42
46
|
}
|
|
43
47
|
]
|
|
@@ -1,13 +1,31 @@
|
|
|
1
|
-
<!-- analytics: page view tracker -->
|
|
1
|
+
<!-- analytics: page view tracker (DNT-aware, session-deduped) -->
|
|
2
2
|
<script>
|
|
3
3
|
(function () {
|
|
4
|
+
if (typeof fetch !== 'function') return;
|
|
5
|
+
|
|
6
|
+
var dnt = navigator.doNotTrack === '1'
|
|
7
|
+
|| window.doNotTrack === '1'
|
|
8
|
+
|| navigator.msDoNotTrack === '1';
|
|
9
|
+
if (dnt) return;
|
|
10
|
+
|
|
4
11
|
var url = window.location.pathname;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
var key = 'dm_analytics_seen';
|
|
15
|
+
var seen = sessionStorage.getItem(key);
|
|
16
|
+
var list = seen ? seen.split('|') : [];
|
|
17
|
+
if (list.indexOf(url) !== -1) return;
|
|
18
|
+
list.push(url);
|
|
19
|
+
sessionStorage.setItem(key, list.join('|'));
|
|
20
|
+
} catch (e) {
|
|
21
|
+
// sessionStorage unavailable (e.g. private mode) — record anyway
|
|
11
22
|
}
|
|
23
|
+
|
|
24
|
+
fetch('/api/plugins/analytics/hit', {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
keepalive: true,
|
|
28
|
+
body: JSON.stringify({ url: url })
|
|
29
|
+
}).catch(function () { /* silent */ });
|
|
12
30
|
})();
|
|
13
31
|
</script>
|