domma-cms 0.66.0 → 0.69.3
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/CLAUDE.md +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +92 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site .gitignore - the content-only allowlist
|
|
3
|
+
*
|
|
4
|
+
* A site repo holds the site's CONTENT and nothing else: pages, media, blocks,
|
|
5
|
+
* views, forms, components, collections the site built, API endpoints, menus,
|
|
6
|
+
* theme and every other config/ file, plus plugins the site wrote itself. The
|
|
7
|
+
* engine - server/, admin/, public/, scripts/, node_modules/ (which carries
|
|
8
|
+
* domma-js and domma-reactive) - and every distributed plugin arrive through
|
|
9
|
+
* the updater, so they are never committed.
|
|
10
|
+
*
|
|
11
|
+
* The file is an ALLOWLIST: ignore everything at the root, then re-include what
|
|
12
|
+
* the site owns. A denylist would have to know every engine path in advance and
|
|
13
|
+
* would start tracking whatever a new release adds; an allowlist can only fail
|
|
14
|
+
* by leaving something out, and the two places a site grows new owned code
|
|
15
|
+
* append their own rule through `allowInGitignore()`:
|
|
16
|
+
* - pluginScaffold.js: a plugin created in the admin is the site's code
|
|
17
|
+
* - pluginInstaller.js: a distributed plugin that stores what users author
|
|
18
|
+
* inside its own folder declares it in plugin.json `contentPaths`
|
|
19
|
+
*
|
|
20
|
+
* ONE COPY, TWO REPOS. domma-cms-manager keeps a byte-identical copy at
|
|
21
|
+
* plugins/site-manager/services/siteGitignore.js (its production install runs
|
|
22
|
+
* an npm domma-cms that may predate this file), and a test there fails when the
|
|
23
|
+
* two drift. Change this one, then copy it across.
|
|
24
|
+
*
|
|
25
|
+
* No imports beyond node built-ins: bin/cli.js loads this before a site exists.
|
|
26
|
+
*/
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
export const MANAGED_HEADER = '# ── Managed by Domma: content-only allowlist (server/services/siteGitignore.js) ──';
|
|
31
|
+
export const SITE_RULES_MARKER = '# ── Site-specific rules (kept when this file is regenerated) ──';
|
|
32
|
+
|
|
33
|
+
/** Top-level entries that belong to the engine or the host, never to the site. */
|
|
34
|
+
export const ENGINE_TOP_LEVEL = new Set([
|
|
35
|
+
'server', 'admin', 'public', 'scripts', 'node_modules', 'bin',
|
|
36
|
+
'package.json', 'package-lock.json', '.cmsVersion', '.domma', 'CLAUDE.md',
|
|
37
|
+
'logs', 'backups', '_backups', '.domma-backups', '.env', '.env.example', '.claude', '.git'
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** Always-kept roots. public/ is handled separately: only site-added entries in it are kept. */
|
|
41
|
+
const SITE_ROOTS = ['content', 'config', 'plugins'];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Plugins that were promoted into core (0.67.0) or retired. A site that has not
|
|
45
|
+
* yet booted a release that removes them still has the folder; it is engine,
|
|
46
|
+
* not the site's.
|
|
47
|
+
*/
|
|
48
|
+
export const RETIRED_PLUGINS = [
|
|
49
|
+
'analytics', 'contacts', 'notes', 'todo', 'demo-viewer', 'theme-switcher', 'site-search'
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Distributed plugins that keep what users AUTHOR inside their own folder, for
|
|
54
|
+
* installs whose plugin.json predates `contentPaths`. Both ship the folder
|
|
55
|
+
* empty, so everything in it was written by the site.
|
|
56
|
+
*/
|
|
57
|
+
export const KNOWN_PLUGIN_CONTENT = {
|
|
58
|
+
'docs': ['data'], // documents, folders, templates, versions
|
|
59
|
+
'theme-roller': ['data/themes'] // themes built in the roller
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Collections whose schema.json the CMS rewrites on every boot
|
|
64
|
+
* (presetCollections.seedAll, roles.seed, userProfiles). Tracking them records
|
|
65
|
+
* the engine's schema, not the site's, and shows a diff after every update.
|
|
66
|
+
* Their data.json is a separate question - see PRIVATE_DATA.
|
|
67
|
+
*/
|
|
68
|
+
export const ENGINE_SCHEMAS = [
|
|
69
|
+
'notifications', 'projects', 'api-tokens', 'api-endpoints', 'notes-notes', 'todo-items',
|
|
70
|
+
'contacts-contacts', 'contacts-groups', 'roles', 'user-profiles'
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Collection entries that are secrets, visitor-submitted or per-user private
|
|
75
|
+
* data, or runtime state - not something the site's authors built. Everything
|
|
76
|
+
* else in content/collections is tracked, including every collection a site
|
|
77
|
+
* creates, projects and api-endpoints.
|
|
78
|
+
*/
|
|
79
|
+
export const PRIVATE_DATA = [
|
|
80
|
+
'notifications', 'api-tokens', 'notes-notes', 'todo-items',
|
|
81
|
+
'contacts-contacts', 'contacts-groups', 'roles', 'user-profiles',
|
|
82
|
+
'user-messages', 'user-message-threads', 'analytics-events', 'enquiries',
|
|
83
|
+
'blog-comments', 'invoices', 'invoice-receivers', 'invoice-issuers',
|
|
84
|
+
'surveys-contacts', 'surveys-invites'
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Lines bin/update.js appends when it cannot find them VERBATIM. They are
|
|
89
|
+
* already ignored by `/*` and `/plugins/*`, but writing them out keeps the
|
|
90
|
+
* updater from editing the file on every site after every release.
|
|
91
|
+
*/
|
|
92
|
+
const UPDATER_REQUIRED = ['.domma-backups/', 'plugins/analytics/data/'];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Split a plugin folder listing into distributed and site-owned.
|
|
96
|
+
*
|
|
97
|
+
* @param {string[]} sitePlugins - Directory names under the site's plugins/
|
|
98
|
+
* @param {Iterable<string>} distributed - Every plugin name an upstream ships
|
|
99
|
+
* @returns {{distributed: string[], siteOwned: string[]}}
|
|
100
|
+
*/
|
|
101
|
+
export function classifyPlugins(sitePlugins, distributed) {
|
|
102
|
+
const known = new Set([...distributed, ...RETIRED_PLUGINS]);
|
|
103
|
+
const out = {distributed: [], siteOwned: []};
|
|
104
|
+
for (const name of [...sitePlugins].sort()) {
|
|
105
|
+
if (name.startsWith('.')) continue;
|
|
106
|
+
(known.has(name) ? out.distributed : out.siteOwned).push(name);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Rules that re-include a site-owned plugin.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} name
|
|
115
|
+
* @returns {string[]}
|
|
116
|
+
*/
|
|
117
|
+
export function sitePluginRules(name) {
|
|
118
|
+
return [`!/plugins/${name}/`];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Rules that re-include ONE content folder inside an otherwise ignored
|
|
123
|
+
* distributed plugin. Git cannot re-include a file whose parent is excluded,
|
|
124
|
+
* so every level is opened and its siblings closed again:
|
|
125
|
+
* data/themes -> !plugins/x/, plugins/x/*, !plugins/x/data/, plugins/x/data/*, !plugins/x/data/themes/
|
|
126
|
+
*
|
|
127
|
+
* @param {string} name - Plugin folder name
|
|
128
|
+
* @param {string} rel - Folder inside the plugin, e.g. 'data' or 'data/themes'
|
|
129
|
+
* @returns {string[]}
|
|
130
|
+
*/
|
|
131
|
+
export function pluginContentRules(name, rel) {
|
|
132
|
+
const parts = rel.split('/').filter(p => p && p !== '.' && p !== '..');
|
|
133
|
+
if (!parts.length) return [];
|
|
134
|
+
const out = [`!/plugins/${name}/`, `/plugins/${name}/*`];
|
|
135
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
136
|
+
const sub = parts.slice(0, i).join('/');
|
|
137
|
+
out.push(`!/plugins/${name}/${sub}/`);
|
|
138
|
+
if (i < parts.length) out.push(`/plugins/${name}/${sub}/*`);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build the .gitignore text.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} [opts]
|
|
147
|
+
* @param {string[]} [opts.sitePlugins=[]] - Site-owned plugin folder names
|
|
148
|
+
* @param {Object<string,string[]>} [opts.pluginContent={}] - Extra content folders in distributed plugins
|
|
149
|
+
* @param {string[]} [opts.sitePublic=[]] - Site-added entries directly under public/
|
|
150
|
+
* @param {string[]} [opts.extras=[]] - Other site-owned top-level entries (docs, tools, README.md)
|
|
151
|
+
* @param {string[]} [opts.siteRules=[]] - The site-specific section
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic = [], extras = [], siteRules = []} = {}) {
|
|
155
|
+
const L = [
|
|
156
|
+
MANAGED_HEADER,
|
|
157
|
+
'# This repo holds the site\'s CONTENT only. Everything is ignored, then what',
|
|
158
|
+
'# the site owns is re-included. The engine (server/, admin/, public/, scripts/,',
|
|
159
|
+
'# node_modules/ incl. domma-js + domma-reactive) and distributed plugins come',
|
|
160
|
+
'# from the updater and are never committed. A plugin created in the admin adds',
|
|
161
|
+
'# its own rule to the site section below.',
|
|
162
|
+
'/*',
|
|
163
|
+
'!/.gitignore'
|
|
164
|
+
];
|
|
165
|
+
for (const root of SITE_ROOTS) L.push(`!/${root}/`);
|
|
166
|
+
for (const x of [...extras].sort()) L.push(`!/${x}${x.includes('.') ? '' : '/'}`);
|
|
167
|
+
|
|
168
|
+
L.push('', '# Plugins: only the ones this site wrote itself', '/plugins/*');
|
|
169
|
+
for (const p of [...sitePlugins].sort()) L.push(...sitePluginRules(p));
|
|
170
|
+
|
|
171
|
+
const content = {};
|
|
172
|
+
for (const [name, rels] of [...Object.entries(KNOWN_PLUGIN_CONTENT), ...Object.entries(pluginContent)]) {
|
|
173
|
+
if (sitePlugins.includes(name)) continue; // already tracked whole
|
|
174
|
+
content[name] = [...new Set([...(content[name] || []), ...rels])];
|
|
175
|
+
}
|
|
176
|
+
L.push('', '# ...and what users author inside a distributed plugin (its code stays out)');
|
|
177
|
+
for (const name of Object.keys(content).sort()) {
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
for (const rel of content[name].sort()) {
|
|
180
|
+
for (const r of pluginContentRules(name, rel)) if (!seen.has(r)) { seen.add(r); L.push(r); }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (sitePublic.length) {
|
|
185
|
+
L.push('', '# public/ is engine except what the site added to it', '!/public/', '/public/*');
|
|
186
|
+
for (const p of [...sitePublic].sort()) L.push(`!/public/${p}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
L.push('', '# Schemas the CMS rewrites on every boot - engine-owned, not authored');
|
|
190
|
+
for (const c of ENGINE_SCHEMAS) L.push(`/content/collections/${c}/schema.json`);
|
|
191
|
+
L.push('', '# Secrets, visitor-submitted and per-user data, runtime state');
|
|
192
|
+
for (const c of PRIVATE_DATA) L.push(`/content/collections/${c}/data.json`);
|
|
193
|
+
L.push(
|
|
194
|
+
'/content/users/',
|
|
195
|
+
'/content/versions/',
|
|
196
|
+
'/content/analytics/',
|
|
197
|
+
'/content/instances_log.json',
|
|
198
|
+
'/content/preview-links.json',
|
|
199
|
+
'/config/connections.json*',
|
|
200
|
+
'/config/keys/',
|
|
201
|
+
'/plugins/*/data/*.jsonl',
|
|
202
|
+
'/plugins/*/backups/',
|
|
203
|
+
...UPDATER_REQUIRED,
|
|
204
|
+
'',
|
|
205
|
+
'# Cruft',
|
|
206
|
+
'*.log', '*.pid', '*.bak', '*.bak-*', '*.orig', '*.swp', '*.prev-*/', '.DS_Store',
|
|
207
|
+
'',
|
|
208
|
+
SITE_RULES_MARKER,
|
|
209
|
+
...siteRules
|
|
210
|
+
);
|
|
211
|
+
return L.join('\n') + '\n';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Every non-comment rule above the site section, for de-duplicating carried rules. */
|
|
215
|
+
export function generatedRules(text) {
|
|
216
|
+
const end = text.indexOf(SITE_RULES_MARKER);
|
|
217
|
+
return (end === -1 ? text : text.slice(0, end)).split('\n').map(l => l.trim())
|
|
218
|
+
.filter(l => l && !l.startsWith('#'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Old rules that would hide a whole content root. The first scaffolded
|
|
223
|
+
* .gitignore ignored content/media/, and media is content - carrying that rule
|
|
224
|
+
* would keep every uploaded image out of the repo that exists to hold them.
|
|
225
|
+
*/
|
|
226
|
+
const NEVER_CARRY = /^(content|config|content\/(media|pages|blocks|components|forms|views|collections))\/?\*?$/;
|
|
227
|
+
|
|
228
|
+
function norm(rule) {
|
|
229
|
+
return rule.replace(/^\//, '').replace(/\/$/, '');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Pull the site-specific section out of an existing .gitignore.
|
|
234
|
+
*
|
|
235
|
+
* A file this module wrote keeps everything after the marker verbatim. A
|
|
236
|
+
* hand-written file has no marker, so only the rules that still narrow
|
|
237
|
+
* something kept (content/, config/, a kept plugin or public entry) are
|
|
238
|
+
* carried; engine paths are covered by the allowlist and dropped.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} existing - Current .gitignore text ('' if none)
|
|
241
|
+
* @param {{keptPlugins: string[], keptPublic: string[]}} kept
|
|
242
|
+
* @param {string[]} generated - Rules the new file already has
|
|
243
|
+
* @returns {string[]}
|
|
244
|
+
*/
|
|
245
|
+
export function carrySiteRules(existing, {keptPlugins, keptPublic}, generated) {
|
|
246
|
+
if (!existing) return [];
|
|
247
|
+
const idx = existing.indexOf(SITE_RULES_MARKER);
|
|
248
|
+
if (idx !== -1) {
|
|
249
|
+
return existing.slice(idx + SITE_RULES_MARKER.length).replace(/^\n+/, '').replace(/\n+$/, '').split('\n')
|
|
250
|
+
.filter(l => l.length);
|
|
251
|
+
}
|
|
252
|
+
const have = new Set(generated.map(norm));
|
|
253
|
+
// A generated `foo*` already covers every carried rule that starts with foo.
|
|
254
|
+
const globs = generated.filter(r => r.endsWith('*') && !r.startsWith('!')).map(r => norm(r).slice(0, -1))
|
|
255
|
+
.filter(g => g.includes('/') && !g.endsWith('/') && !g.includes('*'));
|
|
256
|
+
const keptPrefixes = [
|
|
257
|
+
'content/', 'config/',
|
|
258
|
+
...keptPlugins.map(p => `plugins/${p}/`),
|
|
259
|
+
...keptPublic.map(p => `public/${p}`)
|
|
260
|
+
];
|
|
261
|
+
const carried = [];
|
|
262
|
+
for (const raw of existing.split('\n')) {
|
|
263
|
+
const line = raw.trim();
|
|
264
|
+
if (!line || line.startsWith('#')) continue;
|
|
265
|
+
const bare = line.replace(/^!?\//, '').replace(/^!/, '');
|
|
266
|
+
if (!keptPrefixes.some(p => bare.startsWith(p))) continue;
|
|
267
|
+
if (NEVER_CARRY.test(bare)) continue;
|
|
268
|
+
if (have.has(norm(line)) || globs.some(g => norm(line).startsWith(g))) continue;
|
|
269
|
+
carried.push(line);
|
|
270
|
+
}
|
|
271
|
+
return carried;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Add rules to the site section of a managed .gitignore, skipping any already
|
|
276
|
+
* present. A missing or hand-written file is left alone: the site chose its own
|
|
277
|
+
* rules and this must not rewrite them.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} siteRoot - Directory holding .gitignore
|
|
280
|
+
* @param {string[]} rules
|
|
281
|
+
* @returns {string[]} The rules actually added
|
|
282
|
+
*/
|
|
283
|
+
export function allowInGitignore(siteRoot, rules) {
|
|
284
|
+
const file = path.join(siteRoot, '.gitignore');
|
|
285
|
+
let text;
|
|
286
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
287
|
+
if (!text.startsWith(MANAGED_HEADER) || !text.includes(SITE_RULES_MARKER)) return [];
|
|
288
|
+
const present = new Set(text.split('\n').map(l => l.trim()));
|
|
289
|
+
const added = rules.filter(r => !present.has(r));
|
|
290
|
+
if (added.length) fs.writeFileSync(file, text.replace(/\n*$/, '\n') + added.join('\n') + '\n');
|
|
291
|
+
return added;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The content folders a plugin manifest declares, sanitised. `contentPaths`
|
|
296
|
+
* is a list of folders relative to the plugin root; anything that could climb
|
|
297
|
+
* out of it is dropped.
|
|
298
|
+
*
|
|
299
|
+
* @param {object} manifest - Parsed plugin.json
|
|
300
|
+
* @returns {string[]}
|
|
301
|
+
*/
|
|
302
|
+
export function manifestContentPaths(manifest) {
|
|
303
|
+
const raw = Array.isArray(manifest?.contentPaths) ? manifest.contentPaths : [];
|
|
304
|
+
return raw.filter(p => typeof p === 'string')
|
|
305
|
+
.map(p => p.replace(/^\.?\/+/, '').replace(/\/+$/, ''))
|
|
306
|
+
.filter(p => p && !p.split('/').some(s => s === '..' || s === '.' || s === ''));
|
|
307
|
+
}
|
|
@@ -85,6 +85,20 @@ export function defaultThemeConfig() {
|
|
|
85
85
|
nightStart: '19:00'
|
|
86
86
|
},
|
|
87
87
|
font: {family: 'Roboto', size: 16},
|
|
88
|
+
// The public theme switcher - a floating disc of theme dots. Off by
|
|
89
|
+
// default: a visitor-facing theme picker is a choice a site makes, not
|
|
90
|
+
// something it should acquire by upgrading.
|
|
91
|
+
//
|
|
92
|
+
// `themes: []` means every theme the stylesheet ships. It is a filter,
|
|
93
|
+
// never a list to maintain - listThemeIds() is the source of truth, and
|
|
94
|
+
// the plugin this replaced hardcoded 26 dots against 32 real themes.
|
|
95
|
+
switcher: {
|
|
96
|
+
enabled: false,
|
|
97
|
+
position: 'right-center',
|
|
98
|
+
offsetX: 32,
|
|
99
|
+
offsetY: 32,
|
|
100
|
+
themes: []
|
|
101
|
+
},
|
|
88
102
|
overrides: []
|
|
89
103
|
};
|
|
90
104
|
}
|
|
@@ -223,6 +237,7 @@ function mergeDefaults(raw) {
|
|
|
223
237
|
const cfg = {...d, ...definedOnly(raw)};
|
|
224
238
|
cfg.autoTheme = {...d.autoTheme, ...definedOnly(raw?.autoTheme)};
|
|
225
239
|
cfg.font = {...d.font, ...definedOnly(raw?.font)};
|
|
240
|
+
cfg.switcher = {...d.switcher, ...definedOnly(raw?.switcher)};
|
|
226
241
|
cfg.overrides = Array.isArray(raw?.overrides) ? raw.overrides : [];
|
|
227
242
|
return cfg;
|
|
228
243
|
}
|
|
@@ -298,6 +313,9 @@ export async function saveThemeConfig(input) {
|
|
|
298
313
|
...input,
|
|
299
314
|
autoTheme: {...current.autoTheme, ...(input.autoTheme || {})},
|
|
300
315
|
font: {...current.font, ...(input.font || {})},
|
|
316
|
+
// Merged like the others so a partial save - {switcher: {enabled: true}}
|
|
317
|
+
// from an API client - keeps the placement rather than resetting it.
|
|
318
|
+
switcher: {...current.switcher, ...(input.switcher || {})},
|
|
301
319
|
overrides: Array.isArray(input.overrides) ? input.overrides.map(normaliseOverride) : current.overrides
|
|
302
320
|
});
|
|
303
321
|
cfg.version = 1;
|
|
@@ -435,6 +453,27 @@ export function validateThemeConfig(input) {
|
|
|
435
453
|
}
|
|
436
454
|
}
|
|
437
455
|
}
|
|
456
|
+
if (input.switcher) {
|
|
457
|
+
const sw = input.switcher;
|
|
458
|
+
const POSITIONS = ['right-center', 'left-center', 'bottom-right', 'bottom-left',
|
|
459
|
+
'top-left', 'top-right'];
|
|
460
|
+
if (sw.position !== undefined && !POSITIONS.includes(sw.position)) {
|
|
461
|
+
errors.push(`switcher.position must be one of: ${POSITIONS.join(', ')}`);
|
|
462
|
+
}
|
|
463
|
+
for (const key of ['offsetX', 'offsetY']) {
|
|
464
|
+
const v = sw[key];
|
|
465
|
+
if (v !== undefined && (!Number.isFinite(Number(v)) || Number(v) < 0 || Number(v) > 400)) {
|
|
466
|
+
errors.push(`switcher.${key} must be between 0 and 400`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (sw.themes !== undefined) {
|
|
470
|
+
if (!Array.isArray(sw.themes)) {
|
|
471
|
+
errors.push('switcher.themes must be an array');
|
|
472
|
+
} else {
|
|
473
|
+
for (const id of sw.themes) checkTheme(id, 'switcher.themes');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
438
477
|
if (input.font) {
|
|
439
478
|
const size = input.font.size;
|
|
440
479
|
if (size !== undefined && (!Number.isFinite(Number(size)) || Number(size) < 8 || Number(size) > 48)) {
|
package/config/theme.json
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 1,
|
|
3
|
-
"theme": "silver-dark",
|
|
4
|
-
"adminTheme": "silver-dark",
|
|
5
|
-
"baseTheme": null,
|
|
6
|
-
"autoTheme": {
|
|
7
|
-
"enabled": false,
|
|
8
|
-
"dayTheme": "charcoal-light",
|
|
9
|
-
"nightTheme": "charcoal-dark",
|
|
10
|
-
"dayStart": "07:00",
|
|
11
|
-
"nightStart": "19:00"
|
|
12
|
-
},
|
|
13
|
-
"font": {
|
|
14
|
-
"family": "Nunito",
|
|
15
|
-
"size": 18
|
|
16
|
-
},
|
|
17
|
-
"overrides": []
|
|
18
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
.analytics-filter{max-width:240px}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
<div class="view-header">
|
|
2
|
-
<h1><span data-icon="chart-bar"></span> Analytics</h1>
|
|
3
|
-
<div class="view-header-actions">
|
|
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="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 stat-card">
|
|
27
|
-
<div class="card-body">
|
|
28
|
-
<div class="stat-icon"><span data-icon="activity"></span></div>
|
|
29
|
-
<div>
|
|
30
|
-
<div class="stat-value" id="kpi-range">-</div>
|
|
31
|
-
<div class="stat-label">Hits in range</div>
|
|
32
|
-
</div>
|
|
33
|
-
</div>
|
|
34
|
-
</div>
|
|
35
|
-
<div class="card stat-card">
|
|
36
|
-
<div class="card-body">
|
|
37
|
-
<div class="stat-icon"><span data-icon="file-text"></span></div>
|
|
38
|
-
<div>
|
|
39
|
-
<div class="stat-value" id="kpi-pages">-</div>
|
|
40
|
-
<div class="stat-label">Unique pages</div>
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
</div>
|
|
44
|
-
<div class="card stat-card">
|
|
45
|
-
<div class="card-body">
|
|
46
|
-
<div class="stat-icon stat-icon--success"><span data-icon="trending-up"></span></div>
|
|
47
|
-
<div>
|
|
48
|
-
<div class="stat-value" id="kpi-lifetime">-</div>
|
|
49
|
-
<div class="stat-label">Lifetime hits</div>
|
|
50
|
-
</div>
|
|
51
|
-
</div>
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
54
|
-
|
|
55
|
-
<div class="card mb-4">
|
|
56
|
-
<div class="card-header"><strong>Hits over time</strong></div>
|
|
57
|
-
<div class="card-body">
|
|
58
|
-
<canvas id="trend-chart" height="90"></canvas>
|
|
59
|
-
</div>
|
|
60
|
-
</div>
|
|
61
|
-
|
|
62
|
-
<div class="card">
|
|
63
|
-
<div class="card-header flex justify-between items-center">
|
|
64
|
-
<strong>Top pages</strong>
|
|
65
|
-
<input id="filter-input" type="search" class="form-input form-input-sm analytics-filter" placeholder="Filter URL…">
|
|
66
|
-
</div>
|
|
67
|
-
<div class="card-body">
|
|
68
|
-
<div id="analytics-table"></div>
|
|
69
|
-
</div>
|
|
70
|
-
</div>
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Analytics Plugin - Admin View
|
|
3
|
-
* KPI cards, trend chart (Chart.js via CDN), filterable top-pages table, CSV export.
|
|
4
|
-
*/
|
|
5
|
-
const CHART_CDN = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js';
|
|
6
|
-
|
|
7
|
-
let chartInstance = null;
|
|
8
|
-
let currentRange = '7d';
|
|
9
|
-
let currentTop = [];
|
|
10
|
-
let filterTerm = '';
|
|
11
|
-
|
|
12
|
-
export const analyticsView = {
|
|
13
|
-
templateUrl: '/plugins/analytics/admin/templates/analytics.html',
|
|
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);
|
|
22
|
-
Domma.icons.scan();
|
|
23
|
-
}
|
|
24
|
-
};
|
|
25
|
-
|
|
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;
|
|
44
|
-
try {
|
|
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) {
|
|
61
|
-
E.toast('Could not load analytics data.', { type: 'error' });
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function renderTable($container) {
|
|
66
|
-
const filtered = filterTerm
|
|
67
|
-
? currentTop.filter((row) => row.url.toLowerCase().includes(filterTerm))
|
|
68
|
-
: currentTop;
|
|
69
|
-
|
|
70
|
-
T.create('#analytics-table', {
|
|
71
|
-
data: filtered,
|
|
72
|
-
columns: [
|
|
73
|
-
{ key: 'url', title: 'Page URL', render: (val) => `<code>${val}</code>` },
|
|
74
|
-
{ key: 'hits', title: 'Page views' }
|
|
75
|
-
],
|
|
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);
|
|
175
|
-
});
|
|
176
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
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
|
|
8
|
-
*/
|
|
9
|
-
export default {
|
|
10
|
-
excludeAdmin: true,
|
|
11
|
-
excludePaths: ['/api'],
|
|
12
|
-
honourDnt: true,
|
|
13
|
-
sessionDedup: true
|
|
14
|
-
};
|