domma-cms 0.55.1 → 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.
Files changed (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -1,35 +1,60 @@
1
1
  /**
2
- * Analytics Plugin - Server
3
- * Tracks page hits with per-day buckets so we can chart trends and
4
- * filter by time range.
2
+ * Analytics - page-hit counters, trends and journeys.
5
3
  *
6
- * Storage:
7
- * lifetime.json { "/url": totalHits }
8
- * daily.json { "YYYY-MM-DD": { "/url": hits } }
4
+ * Was the `analytics` plugin until 0.67.0, where it was a CORE_PLUGINS member:
5
+ * always loaded, never switchable off. A feature nobody may turn off has no
6
+ * business being listed as an add-on, so it is simply part of the CMS now.
9
7
  *
10
- * Endpoints (mounted at /api/plugins/analytics):
11
- * POST /hit public record a hit { url, dnt }
8
+ * **Counters live in `content/analytics/`, not under plugins/.** They are real
9
+ * traffic history that cannot be regenerated, and the retirement migration
10
+ * deletes the plugin directory - so the data had to move out from under it. The
11
+ * migration copies and verifies before anything is removed.
12
+ *
13
+ * This is the CLIENT-SIDE beacon's store: a browser posts to /api/analytics/hit,
14
+ * which means bots, blocked scripts and Do-Not-Track visitors are absent by
15
+ * design. `analytics-pro` counts the same site server-side from an onRequest
16
+ * hook and will always report more. They are different instruments, not two
17
+ * versions of one, which is why neither supersedes the other.
18
+ *
19
+ * Endpoints (mounted at /api/analytics):
20
+ * POST /hit public record a hit { url, sid, ref }
12
21
  * GET /stats admin summary + top pages (?range=today|7d|30d|90d|all|custom&from=&to=)
13
22
  * GET /series admin per-day totals series (?days=N or ?from=&to=)
23
+ * GET /journeys admin entry/exit/path aggregates
24
+ * GET /realtime admin sessions active in the last 5 minutes
25
+ * GET /spikes admin days that stand out from the trend
14
26
  * GET /export.csv admin CSV export of top pages for the active range
15
- * DELETE /stats admin reset all stats
27
+ * DELETE /stats admin reset all counters
16
28
  */
29
+ import {clip, plural} from '../../services/sidebarBadges.js';
17
30
  import fs from 'fs/promises';
18
31
  import path from 'path';
19
- import {fileURLToPath} from 'url';
32
+ import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
20
33
 
21
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
+ /**
35
+ * Where the counters live. Resolved per call rather than frozen at import so a
36
+ * test can run against a temporary install, the way the migrations do.
37
+ */
38
+ export function analyticsDir() {
39
+ return path.resolve('content', 'analytics');
40
+ }
22
41
 
23
- // Runtime counters live under data/ - the carve-out that BOTH update paths
24
- // preserve (siteCmsUpdater's copyPlugin backs up data/; `domma-cms update`
25
- // keeps 'data' in its preserve set). Pre-2.1 they sat in the plugin root and
26
- // were overwritten by the manager's copy on every plugin push. On first boot
27
- // migrateToDataDir() moves any pre-2.1 root files across.
28
- const DATA_DIR = path.join(__dirname, 'data');
42
+ const DATA_DIR = analyticsDir();
29
43
  const LIFETIME_FILE = path.join(DATA_DIR, 'lifetime.json');
30
44
  const DAILY_FILE = path.join(DATA_DIR, 'daily.json');
31
45
  const LEGACY_FILE = path.join(DATA_DIR, 'stats.json');
32
46
  const JOURNEYS_FILE = path.join(DATA_DIR, 'journeys.json');
47
+
48
+ /**
49
+ * What never gets counted, server side.
50
+ *
51
+ * These were the plugin's `config.js` defaults and no install ever changed one,
52
+ * so they are constants rather than a config block nobody would open. The other
53
+ * two defaults it carried - honourDnt and sessionDedup - were always decided in
54
+ * the browser, where the facts are, and still are: see public/js/analytics.js.
55
+ */
56
+ const EXCLUDE_ADMIN = true;
57
+ const EXCLUDE_PATHS = ['/api'];
33
58
  const JOURNEY_DAILY_CAP = 5000;
34
59
  const REALTIME_WINDOW_MS = 5 * 60 * 1000;
35
60
 
@@ -48,23 +73,6 @@ async function writeJson(file, data) {
48
73
  await fs.rename(tmp, file);
49
74
  }
50
75
 
51
- /**
52
- * One-shot relocation of pre-2.1 counters from the plugin root into data/.
53
- * Runs before migrateLegacy() so the rest of the plugin only ever sees data/.
54
- */
55
- async function migrateToDataDir() {
56
- await fs.mkdir(DATA_DIR, { recursive: true });
57
- for (const name of ['lifetime.json', 'daily.json', 'journeys.json', 'stats.json']) {
58
- const dest = path.join(DATA_DIR, name);
59
- try { await fs.access(dest); continue; } catch { /* dest absent - maybe move */ }
60
- const oldPath = path.join(__dirname, name);
61
- try {
62
- await fs.access(oldPath);
63
- await fs.rename(oldPath, dest);
64
- } catch { /* no pre-2.1 file to move */ }
65
- }
66
- }
67
-
68
76
  /**
69
77
  * One-shot migration from the legacy flat stats.json.
70
78
  * Preserves existing totals as lifetime numbers; daily history starts empty.
@@ -274,11 +282,17 @@ function shouldRecordJourney(dayEntries, cap = JOURNEY_DAILY_CAP) {
274
282
  return Array.isArray(dayEntries) && dayEntries.length < cap;
275
283
  }
276
284
 
277
- export default async function analyticsPlugin(fastify, options) {
278
- const { authenticate, requireAdmin } = options.auth;
279
- const settings = options.settings || {};
285
+ /**
286
+ * Auth is injectable, the shape dashboard.js and plugins.js use, so a test can
287
+ * drive these routes without registering @fastify/jwt.
288
+ *
289
+ * @param {import('fastify').FastifyInstance} fastify
290
+ * @param {object} [opts]
291
+ */
292
+ export async function analyticsRoutes(fastify, opts = {}) {
293
+ const authenticate = opts.authenticate || defaultAuthenticate;
294
+ const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
280
295
 
281
- await migrateToDataDir();
282
296
  await migrateLegacy();
283
297
 
284
298
  fastify.decorate('analytics', {
@@ -333,11 +347,11 @@ export default async function analyticsPlugin(fastify, options) {
333
347
  }
334
348
 
335
349
  const normalised = url.split('?')[0].split('#')[0].replace(/\/+$/, '') || '/';
336
- if (settings.excludeAdmin && normalised.startsWith('/admin')) {
350
+ if (EXCLUDE_ADMIN && normalised.startsWith('/admin')) {
337
351
  return { ok: true, ignored: 'admin' };
338
352
  }
339
- if (Array.isArray(settings.excludePaths)) {
340
- for (const prefix of settings.excludePaths) {
353
+ if (Array.isArray(EXCLUDE_PATHS)) {
354
+ for (const prefix of EXCLUDE_PATHS) {
341
355
  if (typeof prefix === 'string' && prefix && normalised.startsWith(prefix)) {
342
356
  return { ok: true, ignored: 'excluded' };
343
357
  }
@@ -441,6 +455,40 @@ export default async function analyticsPlugin(fastify, options) {
441
455
  return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
442
456
  });
443
457
 
458
+ /** GET /api/analytics/active-count - the sidebar badge: visitors on the site right now. */
459
+ fastify.get('/active-count', { preHandler: [authenticate, requireAdmin] }, async () => {
460
+ const [journeys, daily] = await Promise.all([readJson(JOURNEYS_FILE, {}), readJson(DAILY_FILE, {})]);
461
+ const today = todayKey();
462
+ const cutoff = Date.now() - REALTIME_WINDOW_MS;
463
+ const recent = (journeys[today] || []).filter(ev => ev.t >= cutoff);
464
+ const n = new Set(recent.map(ev => ev.sid)).size;
465
+ const mins = REALTIME_WINDOW_MS / 60000;
466
+
467
+ // What each active visitor is on now: their latest page.
468
+ const latest = new Map();
469
+ for (const ev of recent) if (!latest.has(ev.sid) || latest.get(ev.sid).t < ev.t) latest.set(ev.sid, ev);
470
+ const onPage = {};
471
+ for (const ev of latest.values()) onPage[ev.url] = (onPage[ev.url] || 0) + 1;
472
+ const viewsToday = Object.values(daily[today] || {}).reduce((a, b) => a + b, 0);
473
+ const sessionsToday = new Set((journeys[today] || []).map(ev => ev.sid)).size;
474
+
475
+ return {
476
+ count: n, label: `${plural(n, 'visitor')} on the site in the last ${mins} minutes`, tone: 'live',
477
+ details: {
478
+ title: 'Analytics',
479
+ rows: [
480
+ {label: 'Active now', value: String(n)},
481
+ {label: 'Views today', value: viewsToday.toLocaleString('en-GB')},
482
+ {label: 'Sessions today', value: sessionsToday.toLocaleString('en-GB')}
483
+ ],
484
+ // URLs come from the PUBLIC hit counter - plain text only, set with textContent.
485
+ items: Object.entries(onPage).sort((a, b) => b[1] - a[1]).slice(0, 5)
486
+ .map(([url, c]) => ({text: clip(url, 48), meta: `${plural(c, 'visitor')} here now`})),
487
+ empty: `Nobody on the site in the last ${mins} minutes.`
488
+ }
489
+ };
490
+ });
491
+
444
492
  fastify.get('/spikes', { preHandler: [authenticate, requireAdmin] }, async () => {
445
493
  const daily = await readJson(DAILY_FILE, {});
446
494
  return { items: detectSpikes(daily) };
@@ -359,6 +359,40 @@ export async function collectionsRoutes(fastify) {
359
359
  }
360
360
  });
361
361
 
362
+ /*
363
+ * Flag one entry as spam without disturbing the rest of it.
364
+ *
365
+ * `updateEntry` REPLACES `data` wholesale, so the obvious client-side PUT
366
+ * of {spam: true} would wipe every other field on the record. The merge
367
+ * has to happen server-side, which is the whole reason this endpoint
368
+ * exists rather than the context menu calling the generic update route.
369
+ *
370
+ * The field defaults to `spam` and need not be declared in the schema -
371
+ * validateEntryData only checks required fields, so an undeclared key is
372
+ * carried through untouched. Guarded by the same collections.update
373
+ * permission as any other write, so a public visitor cannot reach it.
374
+ */
375
+ fastify.post('/collections/:slug/entries/:id/spam', canUpdate, async (request, reply) => {
376
+ const {slug, id} = request.params;
377
+ const field = typeof request.body?.field === 'string' && request.body.field.trim()
378
+ ? request.body.field.trim()
379
+ : 'spam';
380
+ const value = request.body?.spam === false ? false : true;
381
+ try {
382
+ const existing = await getEntry(slug, id);
383
+ if (!existing) return reply.code(404).send({error: 'Entry not found'});
384
+
385
+ const entry = await updateEntry(slug, id, {...(existing.data || {}), [field]: value});
386
+ hooks.emit('collection:entryUpdated', {
387
+ slug, entryId: entry.id, data: entry.data, userId: request.user?.id || null
388
+ });
389
+ return {entry, field, spam: value};
390
+ } catch (err) {
391
+ const code = /not found/i.test(err.message) ? 404 : 400;
392
+ return reply.code(code).send({error: err.message});
393
+ }
394
+ });
395
+
362
396
  fastify.put('/collections/:slug/entries/:id', canUpdate, async (request, reply) => {
363
397
  try {
364
398
  const entry = await updateEntry(request.params.slug, request.params.id, request.body?.data || {});