@materializecms/server 0.4.0 → 0.6.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/src/app.js CHANGED
@@ -9,11 +9,12 @@ import { getConnInfo } from '@hono/node-server/conninfo';
9
9
  import { describeCollection, prepare, SLUG_PATTERN, slugify, titleOf, validate, } from '@materializecms/core';
10
10
  import { documentSlots, isLocaleCode, mergeTranslation, nodeSlots, untranslatedIds, withLocales, } from '@materializecms/core/i18n';
11
11
  import { senderDomain } from '@materializecms/core/mail';
12
- import { normalizeTheme, sameDesign } from '@materializecms/core/page';
12
+ import { expandComponents, expandGlobals, formDefinition, normalizeTheme, sameDesign, walk, } from '@materializecms/core/page';
13
13
  import { addMove, normalizePath } from '@materializecms/core/seo';
14
14
  import { TRACKING_PATTERNS, trackingBlocks } from '@materializecms/core/tracking';
15
15
  import { DEFAULT_UPDATES, isPatch, } from '@materializecms/core/updates';
16
- import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
16
+ import { VOICE_TONES, voicePrompt } from '@materializecms/core/voice';
17
+ import { createActivityStore, createAuthStore, createCommentStore, createContentStore, createMediaStore, createPreviewLinkStore, createReleaseStore, createScheduleStore, createSettingsStore, createSubmissionStore, EmailTakenError, SlugTakenError, } from '@materializecms/db';
17
18
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
18
19
  import { Hono } from 'hono';
19
20
  import { bodyLimit } from 'hono/body-limit';
@@ -24,10 +25,12 @@ import { secureHeaders } from 'hono/secure-headers';
24
25
  import { streamSSE } from 'hono/streaming';
25
26
  import { z } from 'zod';
26
27
  import { serveAdmin } from './admin.js';
27
- import { AiError, complete, EMPTY_AI, isReady, publicAi, runTurn, translateTexts, } from './ai.js';
28
+ import { AiError, complete, EMPTY_AI, isReady, languageName, publicAi, runTurn, translateTexts, } from './ai.js';
28
29
  import { folderEntries, tarGz } from './archive.js';
29
30
  import { createBackups, DEFAULT_BACKUPS } from './backups.js';
31
+ import { briefSystem, briefTools, plain } from './brief.js';
30
32
  import { createFonts, FONT_FILE, FONT_ID } from './fonts.js';
33
+ import { fingerprintOf, readSubmission, removeFiles, saveFiles, sendWebhook, spamReason, } from './forms.js';
31
34
  import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
32
35
  import { createMcpServer } from './mcp.js';
33
36
  import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
@@ -35,10 +38,11 @@ import { DEFAULT_OFFSITE, newRecoveryKey, runOffsite, testOffsite, } from './off
35
38
  import { hashPassword, verifyPassword } from './password.js';
36
39
  import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
37
40
  import { statusPage } from './status-page.js';
38
- import { apiCaller, assistantTools } from './tools.js';
41
+ import { studioSystem, studioTools } from './studio.js';
42
+ import { apiCaller, assistantTools, contentTools, pageTools } from './tools.js';
39
43
  import { newSecret, otpauthUri, recoveryCodes, verifyCode } from './totp.js';
40
44
  import { readRest, readWxr, WordPressError } from './wordpress.js';
41
- import { fold, optionsOf, runImport, sourcesFor, suggest, } from './wpimport.js';
45
+ import { fold, optionsOf, relayoutProblem, restoreAlts, runImport, sourcesFor, suggest, } from './wpimport.js';
42
46
  /** The installed version, from this package's package.json (same place in the sources and in the release). */
43
47
  export const VERSION = createRequire(import.meta.url)('../package.json').version;
44
48
  const SESSION_COOKIE = 'mz_session';
@@ -61,7 +65,10 @@ const FORGOT_THRESHOLD = 3;
61
65
  const FORGOT_IP_THRESHOLD = 10;
62
66
  /** Where people open their one-time link: the admin's welcome screen. */
63
67
  const linkPath = (token) => `/admin/_invite/${token}`;
64
- /** The custom code blocks in a document: it runs on the public site, so only admins may change it. */
68
+ /**
69
+ * The custom code blocks in a document (it runs on the public site) and where its forms send
70
+ * their messages: only admins may change them.
71
+ */
65
72
  function embedsOf(value, out = []) {
66
73
  if (Array.isArray(value))
67
74
  for (const v of value)
@@ -70,6 +77,10 @@ function embedsOf(value, out = []) {
70
77
  const node = value;
71
78
  if (node.type === 'embed' && typeof node.id === 'string')
72
79
  out.push(`${node.id}:${String(node.html ?? '')}`);
80
+ // Where a form's messages go: someone who only edits pages must not divert them.
81
+ const form = node.form;
82
+ if (form && typeof node.id === 'string' && (form.to || form.webhook))
83
+ out.push(`form:${node.id}:${form.to ?? ''}:${form.webhook ?? ''}`);
73
84
  for (const v of Object.values(node))
74
85
  if (v && typeof v === 'object')
75
86
  embedsOf(v, out);
@@ -132,6 +143,8 @@ const siteSeoSchema = z.object({
132
143
  })
133
144
  .optional(),
134
145
  blockAi: z.boolean().optional(),
146
+ llms: z.boolean().optional(),
147
+ feed: z.array(z.string().max(80)).max(50).optional(),
135
148
  disallow: z.array(z.string().trim().max(300)).max(100).optional(),
136
149
  google: short,
137
150
  bing: short,
@@ -179,6 +192,9 @@ export function createApp(base) {
179
192
  const submissions = createSubmissionStore(ctx.database.db);
180
193
  const comments = createCommentStore(ctx.database.db);
181
194
  const activityLog = createActivityStore(ctx.database.db);
195
+ const schedules = createScheduleStore(ctx.database.db);
196
+ const releases = createReleaseStore(ctx.database.db);
197
+ const previewLinks = createPreviewLinkStore(ctx.database.db);
182
198
  const backups = createBackups(ctx.database, () => ctx.config().media.dir);
183
199
  const backupSettings = async () => ({
184
200
  ...DEFAULT_BACKUPS,
@@ -308,12 +324,20 @@ export function createApp(base) {
308
324
  const files = createMediaFiles(ctx.config().media.dir);
309
325
  // Fonts are cached next to the media library (.materialize/fonts by default).
310
326
  const fonts = createFonts(join(dirname(resolve(ctx.config().media.dir)), 'fonts'));
327
+ // Files sent with the site's forms: next to the database, never served by the public site.
328
+ const uploadsDir = join(dirname(resolve(ctx.config().media.dir)), 'uploads');
329
+ let formCache = null;
330
+ /** The live site changed: rebuild it, and read its forms again. */
331
+ const sitePublished = () => {
332
+ formCache = null;
333
+ ctx.onPublish?.();
334
+ };
311
335
  const app = new Hono();
312
336
  const guardEmbeds = (user, before, after) => {
313
337
  if (user.role === 'admin')
314
338
  return;
315
339
  if (JSON.stringify(embedsOf(before).sort()) !== JSON.stringify(embedsOf(after).sort()))
316
- throw new ApiError(403, 'embed_admin_only', 'Solo un amministratore può aggiungere o modificare il codice personalizzato');
340
+ throw new ApiError(403, 'embed_admin_only', 'Solo un amministratore può modificare il codice personalizzato e dove arrivano i messaggi dei moduli');
317
341
  };
318
342
  /** Editors who may only change contents (Persone → Redattori): words, links and images, not the layout. */
319
343
  const contentOnly = async (user) => user.role !== 'admin' && !!(await settings.get('editorial'))?.editorsContentOnly;
@@ -432,13 +456,21 @@ export function createApp(base) {
432
456
  return `/${locale}${path === '/' ? '' : path}`;
433
457
  };
434
458
  /** A document as the admin sees it, with what still needs fixing before it can be published. */
435
- const toJson = (doc) => {
459
+ const toJson = async (doc) => {
436
460
  const def = ctx.config().collections.find((x) => x.name === doc.collection);
437
461
  const check = def ? validate(def, prepare(def, doc.data)) : null;
438
462
  return {
439
463
  ...doc,
440
464
  url: def?.url ? def.url(doc.data) : null,
441
465
  issues: check && !check.ok ? check.issues : [],
466
+ /** When it goes on or off the site by itself, and the last scheduled action that failed. */
467
+ schedule: await schedules.forDocument(doc.id),
468
+ /** The release it goes out with, if any. */
469
+ release: await releases
470
+ .forDocument(doc.id)
471
+ .then((r) => r
472
+ ? { id: r.id, name: r.name, runAt: r.runAt, action: r.items.find((i) => i.documentId === doc.id)?.action }
473
+ : null),
442
474
  };
443
475
  };
444
476
  /**
@@ -628,7 +660,7 @@ export function createApp(base) {
628
660
  : await content.create({ collection: def.name, locale: lang, ...draft });
629
661
  if (doc)
630
662
  await content.publish(doc.id);
631
- ctx.onPublish?.();
663
+ sitePublished();
632
664
  };
633
665
  /** The site's name as set in the panel (the settings singleton, published), else the config's. */
634
666
  const siteName = async () => {
@@ -866,7 +898,7 @@ export function createApp(base) {
866
898
  const ACTIVITY_GROUPS = {
867
899
  access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.', 'password.'],
868
900
  people: ['user.'],
869
- content: ['document.', 'media.', 'message.'],
901
+ content: ['document.', 'release.', 'site.', 'media.', 'message.'],
870
902
  settings: ['settings.', 'backup.', 'update.'],
871
903
  };
872
904
  app.get('/api/activity', requireUser, requireAdmin, async (c) => {
@@ -1102,6 +1134,7 @@ export function createApp(base) {
1102
1134
  const mediaDir = resolve(ctx.config().media.dir);
1103
1135
  entries.push(...(await folderEntries(mediaDir, 'media')));
1104
1136
  entries.push(...(await folderEntries(join(dirname(mediaDir), 'fonts'), 'fonts')));
1137
+ entries.push(...(await folderEntries(uploadsDir, 'uploads')));
1105
1138
  const manifest = join(work, 'materialize.json');
1106
1139
  await writeFile(manifest, JSON.stringify({
1107
1140
  site: ctx.config().site.name,
@@ -1528,7 +1561,7 @@ export function createApp(base) {
1528
1561
  const input = await body(c, z.object({ theme: z.record(z.string(), z.unknown()) }));
1529
1562
  const theme = normalizeTheme(input.theme);
1530
1563
  await settings.set('theme', theme, c.get('user').id);
1531
- ctx.onPublish?.();
1564
+ sitePublished();
1532
1565
  await record(c, 'settings.theme');
1533
1566
  return c.json({ theme });
1534
1567
  });
@@ -1588,7 +1621,7 @@ export function createApp(base) {
1588
1621
  .filter(([id]) => main[id])
1589
1622
  .map(([id, n]) => [id, n]));
1590
1623
  await settings.set(`globals:${lang}`, words, c.get('user').id);
1591
- ctx.onPublish?.();
1624
+ sitePublished();
1592
1625
  return c.json({ globals: await globalsIn(lang) });
1593
1626
  }
1594
1627
  const before = (await settings.get('globals')) ?? {};
@@ -1603,7 +1636,7 @@ export function createApp(base) {
1603
1636
  const ids = [...new Set([...Object.keys(before), ...Object.keys(input.globals)])].sort();
1604
1637
  await guardDesign(c.get('user'), ids.map((id) => before[id] ?? { id, type: 'frame' }), ids.map((id) => input.globals[id] ?? { id, type: 'frame' }));
1605
1638
  await settings.set('globals', input.globals, c.get('user').id);
1606
- ctx.onPublish?.();
1639
+ sitePublished();
1607
1640
  return c.json({ globals: input.globals });
1608
1641
  });
1609
1642
  // ---------- comments and presence ----------
@@ -1803,6 +1836,203 @@ export function createApp(base) {
1803
1836
  await complete(ai, { ...probe, fast: true }).catch(aiFailed);
1804
1837
  return c.json({ ok: true, reply });
1805
1838
  });
1839
+ // ---------- brand voice ----------
1840
+ /** `style` when the AI changes a text someone wrote, `full` when it writes one (see voicePrompt). */
1841
+ const voiceText = async (use = 'full') => voicePrompt(await settings.get('voice'), use);
1842
+ const voiceSchema = z.object({
1843
+ about: z.string().trim().max(1000).optional(),
1844
+ tones: z.array(z.enum(VOICE_TONES)).max(3).optional(),
1845
+ address: z.enum(['tu', 'voi', 'lei']).nullable().optional(),
1846
+ prefer: z.string().trim().max(1000).optional(),
1847
+ avoid: z.string().trim().max(1000).optional(),
1848
+ rules: z.string().trim().max(1500).optional(),
1849
+ example: z.string().trim().max(3000).optional(),
1850
+ });
1851
+ app.get('/api/voice', requireUser, async (c) => c.json({ voice: (await settings.get('voice')) ?? {} }));
1852
+ app.put('/api/voice', requireUser, requireAdmin, async (c) => {
1853
+ const input = await body(c, z.object({ voice: voiceSchema }));
1854
+ await settings.set('voice', input.voice, c.get('user').id);
1855
+ await record(c, 'settings.voice');
1856
+ return c.json({ voice: input.voice });
1857
+ });
1858
+ /**
1859
+ * A brand voice read from the texts already on the site, to start from: nothing is saved, the
1860
+ * panel puts it in the form to check.
1861
+ */
1862
+ app.post('/api/voice/suggest', requireUser, requireAdmin, async (c) => {
1863
+ if (!allowAi(c.get('user').id))
1864
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
1865
+ const ai = await readyAi();
1866
+ const config = ctx.config();
1867
+ const texts = [];
1868
+ let size = 0;
1869
+ for (const def of config.collections) {
1870
+ for (const doc of await content.published(def.name, config.locales.default)) {
1871
+ for (const slot of documentSlots(def.fields, doc.publishedData ?? {})) {
1872
+ const t = slot.text.replace(/<[^>]+>/g, '').trim();
1873
+ if (t.length < 20 || size > 14_000)
1874
+ continue;
1875
+ texts.push(t);
1876
+ size += t.length;
1877
+ }
1878
+ }
1879
+ }
1880
+ if (size < 300)
1881
+ throw new ApiError(400, 'too_little_text', 'Il sito ha ancora pochi testi pubblicati: scrivi la voce a mano');
1882
+ const reply = await complete(ai, {
1883
+ system: 'You describe the voice of a brand from the texts of its website, for a style guide the brand will reuse. Reply with JSON only, no code fences.',
1884
+ prompt: `Read these texts of the website "${config.site.name}" and describe how it speaks, writing in the language of the texts.
1885
+ Reply with this JSON object:
1886
+ {"about": "who the brand is and who it writes for, one or two sentences",
1887
+ "tones": [up to three of ${JSON.stringify(VOICE_TONES)}],
1888
+ "address": "tu" | "voi" | "lei" | null (how readers are addressed),
1889
+ "prefer": "words and phrases typical of these texts, comma separated",
1890
+ "avoid": "words that would sound wrong for this brand, comma separated",
1891
+ "rules": "two or three rules of style seen in the texts, as short sentences",
1892
+ "example": "a short paragraph of new text in this voice, about the brand"}
1893
+
1894
+ <texts>
1895
+ ${texts.join('\n---\n')}
1896
+ </texts>`,
1897
+ }).catch(aiFailed);
1898
+ let raw = null;
1899
+ try {
1900
+ raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
1901
+ }
1902
+ catch { }
1903
+ const parsed = voiceSchema.safeParse(raw && {
1904
+ ...raw,
1905
+ tones: Array.isArray(raw.tones)
1906
+ ? raw.tones.filter((t) => VOICE_TONES.includes(t)).slice(0, 3)
1907
+ : [],
1908
+ address: ['tu', 'voi', 'lei'].includes(raw.address) ? raw.address : null,
1909
+ });
1910
+ if (!parsed.success)
1911
+ throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
1912
+ return c.json({ voice: parsed.data, texts: texts.length });
1913
+ });
1914
+ // ---------- AI review before publishing ----------
1915
+ const REVIEW_KINDS = ['spelling', 'grammar', 'voice', 'consistency', 'link', 'clarity'];
1916
+ /** The site's pages by address, with their titles: what a link on the page points to. */
1917
+ const pageTitles = async () => {
1918
+ const out = new Map();
1919
+ const config = ctx.config();
1920
+ for (const def of config.collections) {
1921
+ if (!def.url)
1922
+ continue;
1923
+ for (const doc of await content.published(def.name)) {
1924
+ const url = publicUrl(def, doc.locale, doc.publishedData ?? {});
1925
+ if (url && out.size < 200)
1926
+ out.set(url, doc.title);
1927
+ }
1928
+ }
1929
+ return out;
1930
+ };
1931
+ /**
1932
+ * The AI reads the draft as it is now (saved or not) and lists what to fix before publishing:
1933
+ * typos, grammar, sentences out of the brand voice, facts that contradict each other, links whose
1934
+ * words promise something else than where they go, unclear sentences. Each with the exact words
1935
+ * and a replacement. Nothing is changed.
1936
+ */
1937
+ app.post('/api/documents/:id/review', requireUser, async (c) => {
1938
+ const existing = await loadDocument(c.req.param('id'));
1939
+ const input = await body(c, z.object({ data: z.record(z.string(), z.unknown()).optional() }));
1940
+ if (!allowAi(c.get('user').id))
1941
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
1942
+ const ai = await readyAi();
1943
+ const def = collection(existing.collection);
1944
+ const slots = documentSlots(def.fields, structuredClone(input.data ?? existing.data));
1945
+ if (!slots.length)
1946
+ throw new ApiError(400, 'empty_text', 'Non c’è testo da rivedere');
1947
+ const titles = await pageTitles();
1948
+ const linkTo = (href) => {
1949
+ const path = href.split('#')[0] || href;
1950
+ const title = titles.get(path.replace(/\/+$/, '') || '/');
1951
+ return title ? `${href} (page "${title}")` : href;
1952
+ };
1953
+ let size = 0;
1954
+ const listed = slots.flatMap((s, i) => {
1955
+ if (size > 30_000)
1956
+ return [];
1957
+ size += s.text.length;
1958
+ const links = s.links?.length ? `\n links: ${s.links.map((l, n) => `${n + 1} → ${linkTo(l)}`).join('; ')}` : '';
1959
+ return [`[${i}] ${s.text}${links}`];
1960
+ });
1961
+ const voice = await voiceText('full');
1962
+ const reply = await complete(ai, {
1963
+ system: `${voice ? `${voice}\n\n` : ''}You review a web page before it is published, as a careful editor. Reply with JSON only, no code fences.`,
1964
+ prompt: `These are the texts of the page "${existing.title}", numbered. Tags like <b>, <i> and <a1>…</a1> mark formatting and links; "links" says where each numbered link goes.
1965
+
1966
+ Find what really needs fixing before publishing, and nothing else:
1967
+ - spelling: typos and misspelled words
1968
+ - grammar: grammar, agreement, punctuation
1969
+ - voice: sentences clearly out of the brand voice above (skip if there is none)
1970
+ - consistency: facts on the page that contradict each other (prices, dates, names, numbers)
1971
+ - link: link words that promise something different from the page they go to
1972
+ - clarity: a sentence a reader would not understand
1973
+
1974
+ Reply with {"issues": [{"slot": number, "kind": one of ${JSON.stringify(REVIEW_KINDS)}, "find": "the exact words to change, copied from that text with its tags", "replace": "the corrected words, or \\"\\" when only a person can fix it", "note": "why, in one short sentence, in the language of the page"}]}.
1975
+ At most 25 issues, the most important first. An empty list is a fine answer when the page is good. Do not report style preferences, the length of texts, or things that are fine.
1976
+
1977
+ <texts>
1978
+ ${listed.join('\n')}
1979
+ </texts>`,
1980
+ }).catch(aiFailed);
1981
+ let raw;
1982
+ try {
1983
+ raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
1984
+ }
1985
+ catch {
1986
+ throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
1987
+ }
1988
+ const issue = z.object({
1989
+ slot: z.number().int().min(0),
1990
+ kind: z.enum(REVIEW_KINDS),
1991
+ find: z.string().min(1).max(2000),
1992
+ replace: z.string().max(4000),
1993
+ note: z.string().max(500),
1994
+ });
1995
+ const items = Array.isArray(raw?.issues) ? raw.issues : [];
1996
+ const issues = items.flatMap((it) => {
1997
+ const parsed = issue.safeParse(it);
1998
+ if (!parsed.success)
1999
+ return [];
2000
+ const slot = slots[parsed.data.slot];
2001
+ // Only what is really in the text: a quote the model made up cannot be applied.
2002
+ if (!slot?.text.includes(parsed.data.find))
2003
+ return [];
2004
+ return [{ ...parsed.data, node: slot.node ?? null, context: slot.text.replace(/<[^>]+>/g, '').slice(0, 160) }];
2005
+ });
2006
+ await record(c, 'document.reviewed', existing.title, {
2007
+ collection: existing.collection,
2008
+ id: existing.id,
2009
+ issues: issues.length,
2010
+ });
2011
+ return c.json({ issues: issues.slice(0, 25) });
2012
+ });
2013
+ /**
2014
+ * Applies one suggestion of the review to the draft as the editor holds it, and gives it back:
2015
+ * the editor takes it as one change, which ⌘Z undoes. Nothing is saved here.
2016
+ */
2017
+ app.post('/api/documents/:id/review/apply', requireUser, async (c) => {
2018
+ const existing = await loadDocument(c.req.param('id'));
2019
+ const input = await body(c, z.object({
2020
+ data: z.record(z.string(), z.unknown()),
2021
+ slot: z.number().int().min(0),
2022
+ find: z.string().min(1).max(2000),
2023
+ replace: z.string().max(4000),
2024
+ }));
2025
+ const data = structuredClone(input.data);
2026
+ const slots = documentSlots(collection(existing.collection).fields, data);
2027
+ // The same text, or the first one that still has those words (the page may have changed since).
2028
+ const slot = slots[input.slot]?.text.includes(input.find)
2029
+ ? slots[input.slot]
2030
+ : slots.find((s) => s.text.includes(input.find));
2031
+ if (!slot)
2032
+ throw new ApiError(409, 'changed', 'Quel testo è cambiato: rifai la revisione');
2033
+ slot.set(slot.text.replace(input.find, input.replace));
2034
+ return c.json({ data });
2035
+ });
1806
2036
  const TEXT_ACTIONS = {
1807
2037
  rewrite: 'Rewrite it so it reads better: same meaning, similar length.',
1808
2038
  shorten: 'Make it shorter, keeping what matters most.',
@@ -1831,8 +2061,12 @@ export function createApp(base) {
1831
2061
  if (!allowAi(c.get('user').id))
1832
2062
  throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
1833
2063
  const ai = await readyAi();
2064
+ // Writing from scratch takes the whole voice; changing a text, only its style.
2065
+ const voice = await voiceText(['seo_title', 'seo_description', 'custom'].includes(input.action) ? 'full' : 'style');
1834
2066
  const lang = input.locale ?? ctx.config().locales.default;
1835
- const system = `You edit texts for a website. Reply with the resulting text only: no quotes, no preamble, no notes. Keep the language of the text${input.action === 'translate' ? ' unless asked to translate' : ''}; when writing from scratch, write in the language with code "${lang}". Keep line breaks where they make sense; never add Markdown.`;
2067
+ const system = `You edit texts for a website. Reply with the resulting text only: no quotes, no preamble, no notes. Keep the language of the text${input.action === 'translate' ? ' unless asked to translate' : ''}; when writing from scratch, write in the language with code "${lang}". Keep line breaks where they make sense; never add Markdown.${
2068
+ // Spelling fixes and alternative texts describe: they do not speak with the brand's voice.
2069
+ input.action === 'fix' || input.action === 'alt' || !voice ? '' : `\n\n${voice}`}`;
1836
2070
  if (input.action === 'alt') {
1837
2071
  const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
1838
2072
  const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
@@ -1925,8 +2159,12 @@ export function createApp(base) {
1925
2159
  const chatId = input.chatId ?? crypto.randomUUID();
1926
2160
  const key = `${user.id}:${chatId}`;
1927
2161
  const entry = chats.get(key);
1928
- const conv = entry && entry.conv.provider === ai.provider ? entry.conv : { provider: ai.provider, messages: [] };
1929
- chats.set(key, { conv, at: now });
2162
+ const same = !!entry && entry.conv.provider === ai.provider;
2163
+ const conv = same && entry ? entry.conv : { provider: ai.provider, messages: [] };
2164
+ // Fixed when the conversation starts (its instructions may not change): a voice changed
2165
+ // meanwhile applies to the next conversation.
2166
+ const voice = same && entry?.voice !== undefined ? entry.voice : await voiceText();
2167
+ chats.set(key, { conv, at: now, voice });
1930
2168
  const before = input.nodes;
1931
2169
  const page = { nodes: before };
1932
2170
  const auth = {};
@@ -1941,7 +2179,7 @@ export function createApp(base) {
1941
2179
  // The instructions stay the same for the whole conversation: the model's earlier reasoning is
1942
2180
  // tied to them (a change is refused) and they are read from the prompt cache. What changes
1943
2181
  // from turn to turn, the page and what is selected, goes with each message instead.
1944
- const system = `You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing a page; each of their messages starts with a <context> note on the page and on what is selected on the canvas.
2182
+ const system = `${voice ? `${voice}\n\n` : ''}You are the assistant inside Materialize, the visual editor of the website "${ctx.config().site.name}". The person (${user.name}) is editing a page; each of their messages starts with a <context> note on the page and on what is selected on the canvas.
1945
2183
  Use the tools to look at the page (get_page, get_node) and change it (edit_page). Your changes go into the editor, where the person sees them at once and can undo them; they are not published.
1946
2184
  Read the page before changing it. Make the changes asked, consistent with the design already there (theme colors and text styles, spacing, the look of nearby sections); prefer the section library for new sections. Keep ids of existing nodes.
1947
2185
  ${limited ? 'This person may change only contents (texts, links, images, alt texts), not the layout or the design: do not add, remove or move nodes, and do not change styles.\n' : ''}Pictures the person attaches are references (a site they like, a mockup, a sketch): take their layout, hierarchy and mood, and build it with this site's sections, theme and texts; never copy their brand, logos or texts, and do not put the pictures on the page.
@@ -1959,7 +2197,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
1959
2197
  tools,
1960
2198
  onEvent: (e) => void send(e),
1961
2199
  });
1962
- chats.set(key, { conv, at: Date.now() });
2200
+ chats.set(key, { conv, at: Date.now(), voice });
1963
2201
  if (page.nodes !== before) {
1964
2202
  if (limited && !sameDesign(before, page.nodes))
1965
2203
  await send({
@@ -1982,59 +2220,427 @@ When done, say in one or two short sentences what you changed. Reply in the pers
1982
2220
  }
1983
2221
  });
1984
2222
  });
1985
- app.post('/api/forms/:form', async (c) => {
2223
+ // A first version of the site from a few lines about the business: new drafts only, theme and
2224
+ // voice proposed. Admins, since the proposal touches the design of the whole site.
2225
+ /** Runs of the site builder, per person, kept two hours: the person may answer the AI and go on. */
2226
+ const briefRuns = new Map();
2227
+ app.post('/api/ai/site', requireUser, requireSession, requireAdmin, async (c) => {
2228
+ const input = await body(c, z
2229
+ .object({
2230
+ brief: z.string().trim().min(20, 'Scrivi qualche riga in più sull’attività').max(6000).optional(),
2231
+ /** A reply in a run already started, with its id. */
2232
+ runId: z.string().max(64).optional(),
2233
+ message: z.string().trim().min(1).max(4000).optional(),
2234
+ theme: z.boolean().default(true),
2235
+ images: z
2236
+ .array(z.object({
2237
+ mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
2238
+ data: z.base64().max(4_000_000),
2239
+ }))
2240
+ .max(2)
2241
+ .default([]),
2242
+ })
2243
+ .refine((v) => (v.runId ? !!v.message : !!v.brief), { message: 'Scrivi il brief' }));
2244
+ const user = c.get('user');
2245
+ if (!allowAi(user.id))
2246
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2247
+ const ai = await readyAi();
2248
+ const now = Date.now();
2249
+ for (const [k, v] of briefRuns)
2250
+ if (now - v.at > 2 * 60 * 60 * 1000)
2251
+ briefRuns.delete(k);
2252
+ const runId = input.runId ?? crypto.randomUUID();
2253
+ const key = `${user.id}:${runId}`;
2254
+ let run = input.runId ? briefRuns.get(key) : undefined;
2255
+ if (input.runId && (!run || run.conv.provider !== ai.provider))
2256
+ throw new ApiError(409, 'run_expired', 'Questa conversazione non c’è più: parti da un nuovo brief');
2257
+ if (!run) {
2258
+ const config = ctx.config();
2259
+ const pageCollections = config.collections.filter((d) => !d.singleton && Object.values(d.fields).some((f) => f.kind === 'page'));
2260
+ if (!pageCollections.length)
2261
+ throw new ApiError(400, 'no_pages', 'Il sito non ha una raccolta di pagine costruite sulla tela');
2262
+ const locale = config.locales.default;
2263
+ const existing = [];
2264
+ for (const d of config.collections.filter((x) => !x.singleton)) {
2265
+ const { items } = await content.list(d.name, { locale, limit: 100 });
2266
+ for (const i of items)
2267
+ existing.push(`${d.name}: "${i.title}"${i.slug ? ` (slug ${i.slug})` : ''}`);
2268
+ }
2269
+ // Fixed for the whole run, like the assistant's: the model's reasoning is tied to them.
2270
+ const voice = await voiceText();
2271
+ const askVoice = !voice;
2272
+ run = {
2273
+ conv: { provider: ai.provider, messages: [] },
2274
+ system: briefSystem({
2275
+ site: config.site.name,
2276
+ language: languageName(locale),
2277
+ collections: pageCollections.map((d) => `${d.name} (${d.label ?? d.name})`).join(', '),
2278
+ existing: existing.length ? existing.join('; ') : 'none: the site is empty',
2279
+ voice,
2280
+ askVoice,
2281
+ theme: input.theme,
2282
+ }),
2283
+ result: { created: [], theme: null, voice: null },
2284
+ askVoice,
2285
+ theme: input.theme,
2286
+ at: now,
2287
+ };
2288
+ }
2289
+ const current = run;
2290
+ current.at = now;
2291
+ briefRuns.set(key, current);
2292
+ const auth = {};
2293
+ const authorization = c.req.header('authorization');
2294
+ const cookie = c.req.header('cookie');
2295
+ if (authorization)
2296
+ auth.authorization = authorization;
2297
+ else if (cookie)
2298
+ auth.cookie = cookie;
2299
+ return streamSSE(c, async (stream) => {
2300
+ const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
2301
+ const stop = new AbortController();
2302
+ stream.onAbort(() => stop.abort());
2303
+ const { tools, result } = briefTools(apiCaller(app.fetch, auth), {
2304
+ askVoice: current.askVoice,
2305
+ result: current.result,
2306
+ onCreated: (doc) => void send({ type: 'created', doc }),
2307
+ });
2308
+ const before = result.created.length;
2309
+ try {
2310
+ const reply = await runTurn(ai, current.conv, {
2311
+ system: current.system,
2312
+ text: input.runId ? (input.message ?? '') : `<brief>\n${input.brief}\n</brief>`,
2313
+ images: input.images,
2314
+ tools: current.theme ? tools : tools.filter((t) => t.name !== 'propose_theme'),
2315
+ onEvent: (e) => void send(e),
2316
+ maxSteps: 80,
2317
+ signal: stop.signal,
2318
+ });
2319
+ if (result.created.length > before)
2320
+ await record(c, 'site.generated', '', { pages: result.created.length - before });
2321
+ await send({ type: 'result', ...result });
2322
+ await send({ type: 'text', text: reply });
2323
+ await send({ type: 'done', runId });
2324
+ }
2325
+ catch (error) {
2326
+ if (!(error instanceof AiError))
2327
+ console.error(error);
2328
+ // What was made before the failure stays, as drafts: the panel lists it. The run ends.
2329
+ briefRuns.delete(key);
2330
+ await send({ type: 'result', ...result });
2331
+ await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2332
+ await send({ type: 'done' });
2333
+ }
2334
+ });
2335
+ });
2336
+ /** Requests from the dashboard, per person, kept two hours so the person can answer and go on. */
2337
+ const studioRuns = new Map();
2338
+ app.post('/api/ai/studio', requireUser, async (c) => {
2339
+ const input = await body(c, z.object({
2340
+ runId: z.string().max(64).optional(),
2341
+ message: z.string().trim().min(1).max(6000),
2342
+ images: z
2343
+ .array(z.object({
2344
+ mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
2345
+ data: z.base64().max(4_000_000),
2346
+ }))
2347
+ .max(4)
2348
+ .default([]),
2349
+ }));
2350
+ const user = c.get('user');
2351
+ if (!allowAi(user.id))
2352
+ throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
2353
+ const ai = await readyAi();
2354
+ const now = Date.now();
2355
+ for (const [k, v] of studioRuns)
2356
+ if (now - v.at > 2 * 60 * 60 * 1000)
2357
+ studioRuns.delete(k);
2358
+ const runId = input.runId ?? crypto.randomUUID();
2359
+ const key = `${user.id}:${runId}`;
2360
+ const found = input.runId ? studioRuns.get(key) : undefined;
2361
+ const run = found && found.conv.provider === ai.provider
2362
+ ? found
2363
+ : {
2364
+ conv: { provider: ai.provider, messages: [] },
2365
+ // Fixed for the whole conversation: the model's reasoning is tied to it.
2366
+ system: studioSystem({
2367
+ site: ctx.config().site.name,
2368
+ user: user.name,
2369
+ voice: await voiceText(),
2370
+ limited: await contentOnly(user),
2371
+ }),
2372
+ at: now,
2373
+ };
2374
+ run.at = now;
2375
+ studioRuns.set(key, run);
2376
+ const credentials = credentialsOf(c);
2377
+ return streamSSE(c, async (stream) => {
2378
+ const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
2379
+ const stop = new AbortController();
2380
+ stream.onAbort(() => stop.abort());
2381
+ const seen = new Set();
2382
+ const tools = studioTools(apiCaller(app.fetch, credentials), (doc) => {
2383
+ if (seen.has(doc.id))
2384
+ return;
2385
+ seen.add(doc.id);
2386
+ void send({ type: 'touched', doc });
2387
+ });
2388
+ try {
2389
+ const reply = await runTurn(ai, run.conv, {
2390
+ system: run.system,
2391
+ text: input.message,
2392
+ images: input.images,
2393
+ tools,
2394
+ onEvent: (e) => void send(e),
2395
+ maxSteps: 40,
2396
+ signal: stop.signal,
2397
+ });
2398
+ if (seen.size)
2399
+ await record(c, 'site.ai_request', '', { documents: seen.size });
2400
+ await send({ type: 'text', text: reply });
2401
+ await send({ type: 'done', runId });
2402
+ }
2403
+ catch (error) {
2404
+ if (!(error instanceof AiError))
2405
+ console.error(error);
2406
+ studioRuns.delete(key);
2407
+ await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
2408
+ await send({ type: 'done' });
2409
+ }
2410
+ });
2411
+ });
2412
+ // ---------- forms ----------
2413
+ /**
2414
+ * Every form on the published site, by id, as the server checks it: canvas pages with their
2415
+ * shared sections and components expanded, like the site renders them. Kept a minute.
2416
+ */
2417
+ const publishedForms = async () => {
2418
+ if (formCache && Date.now() - formCache.at < 60_000)
2419
+ return formCache.forms;
2420
+ const forms = new Map();
2421
+ const globals = (await settings.get('globals')) ?? {};
2422
+ const components = (await settings.get('components')) ?? {};
2423
+ const collect = (nodes) => walk(expandComponents(expandGlobals(nodes, globals), components), (n) => {
2424
+ const def = formDefinition(n);
2425
+ if (def && !forms.has(def.id))
2426
+ forms.set(def.id, def);
2427
+ });
2428
+ for (const def of ctx.config().collections) {
2429
+ const pageFields = Object.entries(def.fields)
2430
+ .filter(([, f]) => f.kind === 'page')
2431
+ .map(([name]) => name);
2432
+ if (!pageFields.length)
2433
+ continue;
2434
+ for (const doc of await content.published(def.name)) {
2435
+ for (const name of pageFields) {
2436
+ const nodes = doc.publishedData?.[name];
2437
+ if (Array.isArray(nodes))
2438
+ collect(nodes);
2439
+ }
2440
+ }
2441
+ }
2442
+ collect(Object.values(globals));
2443
+ formCache = { at: Date.now(), forms };
2444
+ return forms;
2445
+ };
2446
+ const formsSettings = async () => (await settings.get('forms')) ?? {};
2447
+ /** The secret that signs webhook calls, made the first time it is needed. */
2448
+ const webhookSecret = async () => {
2449
+ const current = await formsSettings();
2450
+ if (current.webhookSecret)
2451
+ return current.webhookSecret;
2452
+ const secret = `whsec_${randomBytes(24).toString('base64url')}`;
2453
+ await settings.set('forms', { ...current, webhookSecret: secret });
2454
+ return secret;
2455
+ };
2456
+ app.post('/api/forms/:form', bodyLimit({
2457
+ maxSize: 25 * 1024 * 1024,
2458
+ onError: () => {
2459
+ throw new ApiError(413, 'file_too_large', 'Gli allegati sono troppo grandi');
2460
+ },
2461
+ }), async (c) => {
1986
2462
  const form = c.req.param('form').slice(0, 80);
1987
2463
  const ip = clientIp(c);
1988
2464
  if (!allowForm(`${ip}:${form}`))
1989
2465
  throw new ApiError(429, 'too_many_attempts', 'Troppi messaggi: riprova tra un minuto');
1990
- const isJson = (c.req.header('content-type') ?? '').includes('application/json');
2466
+ const type = c.req.header('content-type') ?? '';
2467
+ const isJson = type.includes('application/json');
2468
+ const wantsJson = isJson || (c.req.header('accept') ?? '').includes('application/json');
1991
2469
  const raw = (isJson ? await c.req.json().catch(() => ({})) : await c.req.parseBody());
1992
2470
  const page = typeof raw._page === 'string' ? raw._page.slice(0, 300) : (c.req.header('referer') ?? '').slice(0, 300);
1993
- // A field people never see: bots fill it in.
1994
- const trap = typeof raw._gotcha === 'string' && raw._gotcha.length > 0;
1995
- const data = {};
1996
- for (const [k, v] of Object.entries(raw)) {
1997
- if (k.startsWith('_') || typeof v !== 'string' || Object.keys(data).length >= 30)
1998
- continue;
1999
- data[k.slice(0, 80)] = v.slice(0, 5000);
2000
- }
2001
- if (!trap && Object.values(data).some((v) => v.trim())) {
2002
- const saved = await submissions.add({ form, page, data });
2471
+ const back = (c.req.header('referer') ?? '/').split('#')[0];
2472
+ // A field people never see: bots fill it in. They get the same answer as everyone.
2473
+ if (typeof raw._gotcha === 'string' && raw._gotcha.length > 0)
2474
+ return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
2475
+ const def = (await publishedForms()).get(form) ?? null;
2476
+ const { data, files, issues } = await readSubmission(raw, def);
2477
+ if (issues.length) {
2478
+ if (!wantsJson)
2479
+ return c.redirect(`${back}#non-inviato`, 303);
2480
+ return c.json({ error: { code: 'invalid', message: 'Controlla i campi evidenziati', issues } }, 422);
2481
+ }
2482
+ if (!Object.values(data).some((v) => v.trim()) && !files.length)
2483
+ return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
2484
+ const elapsed = typeof raw._t === 'string' && /^\d+$/.test(raw._t) ? Number(raw._t) : null;
2485
+ const fingerprint = fingerprintOf(form, data);
2486
+ const spam = spamReason(data, elapsed) ??
2487
+ ((await submissions.seen(fingerprint, new Date(Date.now() - 86_400_000))) ? 'Messaggio ripetuto' : null);
2488
+ const formName = def?.name?.trim() || def?.subject?.trim() || '';
2489
+ const saved = await submissions.add({ form, formName, page, data, spam, fingerprint });
2490
+ if (files.length)
2491
+ await submissions.setFiles(saved.id, await saveFiles(uploadsDir, saved.id, files));
2492
+ // Spam stays in the panel, recoverable, but nobody is notified.
2493
+ if (!spam) {
2003
2494
  const mail = await settings.get('mail');
2004
- if (isConfigured(mail) && mail.to) {
2005
- const replyTo = Object.entries(data).find(([k, v]) => /mail/i.test(k) && /^[^\s@]+@[^\s@]+$/.test(v))?.[1];
2495
+ const to = (def?.to || mail?.to || '').trim();
2496
+ const sender = Object.entries(data).find(([k, v]) => (def?.fields.find((f) => f.name === k)?.kind === 'email' || /mail/i.test(k)) && /^[^\s@]+@[^\s@]+$/.test(v))?.[1];
2497
+ if (isConfigured(mail) && to) {
2498
+ const subject = def?.subject || (typeof raw._subject === 'string' ? raw._subject.slice(0, 120) : '');
2499
+ const attached = files.length
2500
+ ? `\n\nAllegati (${files.map((f) => f.name).join(', ')}): li trovi in Impostazioni → Messaggi.`
2501
+ : '';
2006
2502
  sendMail(mail, {
2007
- to: mail.to,
2008
- subject: `Nuovo messaggio dal sito${typeof raw._subject === 'string' ? `: ${raw._subject.slice(0, 120)}` : ''}`,
2009
- text: submissionText(page, data),
2010
- replyTo,
2503
+ to,
2504
+ subject: `Nuovo messaggio dal sito${subject ? `: ${subject}` : ''}`,
2505
+ text: submissionText(page, data) + attached,
2506
+ replyTo: sender,
2011
2507
  })
2012
2508
  .then(() => submissions.markMailed(saved.id))
2013
2509
  .catch((error) => console.error('Materialize: email not sent', error));
2510
+ // An answer to whoever wrote: the site's own words only, never what was sent.
2511
+ if (sender && def?.autoReply?.text?.trim())
2512
+ sendMail(mail, {
2513
+ to: sender,
2514
+ subject: def.autoReply.subject?.trim() || 'Abbiamo ricevuto il tuo messaggio',
2515
+ text: def.autoReply.text,
2516
+ replyTo: to.split(',')[0]?.trim(),
2517
+ }).catch((error) => console.error('Materialize: automatic reply not sent', error));
2518
+ }
2519
+ if (def?.webhook) {
2520
+ const payload = {
2521
+ id: saved.id,
2522
+ form: { id: form, name: formName },
2523
+ page,
2524
+ data,
2525
+ files: files.map((f) => ({ field: f.field, name: f.name, size: f.bytes.length })),
2526
+ createdAt: saved.createdAt.toISOString(),
2527
+ };
2528
+ void webhookSecret()
2529
+ .then((secret) => sendWebhook(def.webhook, payload, secret))
2530
+ .then((ok) => submissions.setWebhook(saved.id, ok ? 'ok' : 'failed'))
2531
+ .catch((error) => console.error('Materialize: webhook not sent', error));
2014
2532
  }
2015
2533
  }
2016
2534
  // Without JavaScript the browser posted the form itself: back to the page, marked as sent.
2017
- if (!isJson) {
2018
- const back = (c.req.header('referer') ?? '/').split('#')[0];
2019
- return c.redirect(`${back}#inviato`, 303);
2020
- }
2021
- return c.json({ ok: true });
2535
+ return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
2536
+ });
2537
+ const filterOf = (c) => ({
2538
+ form: c.req.query('form') || undefined,
2539
+ status: ['unread', 'spam'].find((s) => s === c.req.query('status')),
2540
+ q: c.req.query('q')?.slice(0, 200) || undefined,
2022
2541
  });
2023
2542
  app.get('/api/submissions', requireUser, async (c) => {
2024
2543
  const limit = Math.min(Number(c.req.query('limit') ?? 50), 200);
2025
2544
  const offset = Math.max(Number(c.req.query('offset') ?? 0), 0);
2026
- return c.json(await submissions.list(limit, offset));
2545
+ return c.json({
2546
+ ...(await submissions.list(limit, offset, filterOf(c))),
2547
+ forms: await submissions.forms(),
2548
+ });
2549
+ });
2550
+ /** The messages as a spreadsheet (one column per field), with the filters of the list. */
2551
+ app.get('/api/submissions/export.csv', requireUser, async (c) => {
2552
+ const { items } = await submissions.list(10_000, 0, filterOf(c));
2553
+ const fields = [...new Set(items.flatMap((m) => Object.keys(m.data)))];
2554
+ const cell = (v) => {
2555
+ // A leading =, +, - or @ would run as a formula in a spreadsheet.
2556
+ const safe = /^[=+\-@\t\r]/.test(v) ? `'${v}` : v;
2557
+ return /[",\n;]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
2558
+ };
2559
+ const rows = [
2560
+ ['Data', 'Modulo', 'Pagina', ...fields],
2561
+ ...items.map((m) => [
2562
+ m.createdAt.toISOString(),
2563
+ m.formName || m.form,
2564
+ m.page,
2565
+ ...fields.map((f) => m.data[f] ?? ''),
2566
+ ]),
2567
+ ];
2568
+ await record(c, 'message.exported', '', { count: items.length });
2569
+ // With the byte order mark, Excel reads accented letters right.
2570
+ return new Response(`${rows.map((r) => r.map(cell).join(',')).join('\r\n')}\r\n`, {
2571
+ headers: {
2572
+ 'content-type': 'text/csv; charset=utf-8',
2573
+ 'content-disposition': `attachment; filename="messaggi-${new Date().toISOString().slice(0, 10)}.csv"`,
2574
+ 'cache-control': 'no-store',
2575
+ },
2576
+ });
2027
2577
  });
2028
2578
  app.patch('/api/submissions/:id', requireUser, async (c) => {
2029
- const input = await body(c, z.object({ read: z.boolean() }));
2030
- await submissions.markRead(c.req.param('id'), input.read);
2579
+ const input = await body(c, z.object({ read: z.boolean().optional(), spam: z.boolean().optional() }));
2580
+ const id = c.req.param('id');
2581
+ if (input.read !== undefined)
2582
+ await submissions.markRead(id, input.read);
2583
+ if (input.spam !== undefined)
2584
+ await submissions.markSpam(id, input.spam ? 'Segnato a mano' : null);
2031
2585
  return c.json({ ok: true });
2032
2586
  });
2587
+ /** A file sent with a message: always downloaded, never opened in the panel's own page. */
2588
+ app.get('/api/submissions/:id/files/:n', requireUser, async (c) => {
2589
+ const message = await submissions.get(c.req.param('id'));
2590
+ const file = message?.files[Number(c.req.param('n'))];
2591
+ if (!message || !file)
2592
+ throw new ApiError(404, 'not_found', 'File non trovato');
2593
+ const bytes = await readFile(join(uploadsDir, file.path)).catch(() => null);
2594
+ if (!bytes)
2595
+ throw new ApiError(404, 'not_found', 'Il file non c’è più');
2596
+ return new Response(bytes, {
2597
+ headers: {
2598
+ 'content-type': 'application/octet-stream',
2599
+ 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
2600
+ 'x-content-type-options': 'nosniff',
2601
+ 'cache-control': 'no-store',
2602
+ },
2603
+ });
2604
+ });
2033
2605
  app.delete('/api/submissions/:id', requireUser, requireAdmin, async (c) => {
2034
2606
  await submissions.remove(c.req.param('id'));
2607
+ await removeFiles(uploadsDir, c.req.param('id'));
2035
2608
  await record(c, 'message.deleted');
2036
2609
  return c.body(null, 204);
2037
2610
  });
2611
+ /** How long messages are kept, and the key to check webhook calls (admins only see the key). */
2612
+ app.get('/api/forms/settings', requireUser, async (c) => {
2613
+ const current = await formsSettings();
2614
+ const admin = c.get('user').role === 'admin';
2615
+ return c.json({
2616
+ keepMonths: current.keepMonths ?? null,
2617
+ webhookSecret: admin ? await webhookSecret() : null,
2618
+ });
2619
+ });
2620
+ app.put('/api/forms/settings', requireUser, requireAdmin, async (c) => {
2621
+ const input = await body(c, z.object({ keepMonths: z.number().int().min(1).max(120).nullable() }));
2622
+ const current = await formsSettings();
2623
+ await settings.set('forms', { ...current, keepMonths: input.keepMonths ?? undefined }, c.get('user').id);
2624
+ await record(c, 'settings.forms', '', { keepMonths: input.keepMonths });
2625
+ void pruneMessages();
2626
+ return c.json({ keepMonths: input.keepMonths });
2627
+ });
2628
+ /** Deletes the messages older than the kept months, with their files. */
2629
+ const pruneMessages = async () => {
2630
+ const months = (await formsSettings()).keepMonths;
2631
+ if (!months)
2632
+ return 0;
2633
+ const before = new Date();
2634
+ before.setMonth(before.getMonth() - months);
2635
+ const old = await submissions.olderThan(before);
2636
+ for (const m of old) {
2637
+ await submissions.remove(m.id);
2638
+ await removeFiles(uploadsDir, m.id);
2639
+ }
2640
+ return old.length;
2641
+ };
2642
+ setTimeout(() => void pruneMessages().catch(() => { }), 10_000).unref?.();
2643
+ setInterval(() => void pruneMessages().catch(() => { }), 6 * 3_600_000).unref?.();
2038
2644
  // Who may change what: editors limited to contents, if the admins want so.
2039
2645
  app.get('/api/editorial', requireUser, async (c) => c.json({ editorial: (await settings.get('editorial')) ?? {} }));
2040
2646
  app.put('/api/editorial', requireUser, requireSession, requireAdmin, async (c) => {
@@ -2087,7 +2693,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2087
2693
  }),
2088
2694
  }));
2089
2695
  await settings.set('tracking', input.tracking, c.get('user').id);
2090
- ctx.onPublish?.();
2696
+ sitePublished();
2091
2697
  await record(c, 'settings.tracking', '', {
2092
2698
  services: trackingBlocks(input.tracking).map((b) => b.name),
2093
2699
  });
@@ -2098,7 +2704,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2098
2704
  app.put('/api/seo', requireUser, requireAdmin, async (c) => {
2099
2705
  const input = await body(c, z.object({ seo: siteSeoSchema }));
2100
2706
  await settings.set('seo', input.seo, c.get('user').id);
2101
- ctx.onPublish?.();
2707
+ sitePublished();
2102
2708
  await record(c, 'settings.seo');
2103
2709
  return c.json({ seo: input.seo });
2104
2710
  });
@@ -2124,7 +2730,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2124
2730
  .map((r) => ({ ...r, from: normalizePath(r.from), to: /^https?:\/\//.test(r.to) ? r.to : normalizePath(r.to) }))
2125
2731
  .filter((r, i, all) => r.from !== r.to && all.findIndex((o) => o.from === r.from) === i);
2126
2732
  await settings.set('redirects', redirects, c.get('user').id);
2127
- ctx.onPublish?.();
2733
+ sitePublished();
2128
2734
  await record(c, 'settings.redirects', '', { count: redirects.length });
2129
2735
  return c.json({ redirects });
2130
2736
  });
@@ -2133,7 +2739,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2133
2739
  app.put('/api/templates', requireUser, async (c) => {
2134
2740
  const input = await body(c, z.object({ templates: z.record(z.string(), z.string()) }));
2135
2741
  await settings.set('templates', input.templates, c.get('user').id);
2136
- ctx.onPublish?.();
2742
+ sitePublished();
2137
2743
  return c.json({ templates: input.templates });
2138
2744
  });
2139
2745
  // Reusable components with their variants.
@@ -2145,7 +2751,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2145
2751
  if (JSON.stringify(before) !== JSON.stringify(input.components) && (await contentOnly(c.get('user'))))
2146
2752
  throw designLocked();
2147
2753
  await settings.set('components', input.components, c.get('user').id);
2148
- ctx.onPublish?.();
2754
+ sitePublished();
2149
2755
  return c.json({ components: input.components });
2150
2756
  });
2151
2757
  // Entries with their data, for lists bound to a collection on the canvas.
@@ -2233,7 +2839,18 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2233
2839
  return null;
2234
2840
  }
2235
2841
  };
2236
- return c.json({ ...result, items: result.items.map((i) => ({ ...i, url: urlOf(i.slug) })) });
2842
+ // What goes on or off the site by itself, for a label in the list.
2843
+ const pending = await schedules.pending();
2844
+ const when = (id, action) => pending.find((p) => p.documentId === id && p.action === action)?.runAt ?? null;
2845
+ return c.json({
2846
+ ...result,
2847
+ items: result.items.map((i) => ({
2848
+ ...i,
2849
+ url: urlOf(i.slug),
2850
+ publishAt: when(i.id, 'publish'),
2851
+ unpublishAt: when(i.id, 'unpublish'),
2852
+ })),
2853
+ });
2237
2854
  });
2238
2855
  app.post('/api/collections/:name', requireUser, async (c) => {
2239
2856
  const def = collection(c.req.param('name'));
@@ -2246,7 +2863,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2246
2863
  guardEmbeds(c.get('user'), null, draft.data);
2247
2864
  const doc = await slugGuard(() => content.create({ collection: def.name, locale: lang, ...draft, userId: c.get('user').id }));
2248
2865
  await record(c, 'document.created', doc.title, { collection: def.name, id: doc.id });
2249
- return c.json(toJson(doc), 201);
2866
+ return c.json(await toJson(doc), 201);
2250
2867
  });
2251
2868
  const loadDocument = async (id) => {
2252
2869
  const doc = await content.get(id);
@@ -2254,7 +2871,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2254
2871
  throw new ApiError(404, 'not_found', 'This document does not exist');
2255
2872
  return doc;
2256
2873
  };
2257
- app.get('/api/documents/:id', requireUser, async (c) => c.json(toJson(await loadDocument(c.req.param('id')))));
2874
+ app.get('/api/documents/:id', requireUser, async (c) => c.json(await toJson(await loadDocument(c.req.param('id')))));
2258
2875
  app.put('/api/documents/:id', requireUser, async (c) => {
2259
2876
  const existing = await loadDocument(c.req.param('id'));
2260
2877
  const input = await body(c, documentBody);
@@ -2265,10 +2882,10 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2265
2882
  if (f.kind === 'page')
2266
2883
  await guardDesign(c.get('user'), existing.data[name], draft.data[name]);
2267
2884
  const doc = await slugGuard(() => content.update(existing.id, { ...draft, userId: c.get('user').id }));
2268
- return c.json(toJson(doc));
2885
+ return c.json(await toJson(doc));
2269
2886
  });
2270
- app.post('/api/documents/:id/publish', requireUser, async (c) => {
2271
- const existing = await loadDocument(c.req.param('id'));
2887
+ /** Puts a document's draft on the site: the same steps from the button and from a schedule. */
2888
+ const publishDocument = async (existing, userId) => {
2272
2889
  // Validated now, not at save time: drafts may be incomplete and the schema may have changed since.
2273
2890
  const def = collection(existing.collection);
2274
2891
  const data = publishableData(def, existing.data);
@@ -2282,26 +2899,390 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2282
2899
  ? addMove(redirects, before, after)
2283
2900
  : redirects.filter((r) => normalizePath(r.from) !== normalizePath(after));
2284
2901
  if (next.length !== redirects.length || next.some((r, i) => r !== redirects[i]))
2285
- await settings.set('redirects', next, c.get('user').id);
2902
+ await settings.set('redirects', next, userId);
2286
2903
  }
2287
- const doc = (await content.publish(existing.id, c.get('user').id, data));
2288
- ctx.onPublish?.();
2289
- await record(c, 'document.published', doc.title, { collection: def.name, id: doc.id, url: after });
2290
- return c.json(toJson(doc));
2904
+ const doc = (await content.publish(existing.id, userId, data));
2905
+ sitePublished();
2906
+ return { doc, url: after };
2907
+ };
2908
+ const unpublishDocument = async (existing, userId) => {
2909
+ const doc = (await content.unpublish(existing.id, userId));
2910
+ if (existing.publishedAt)
2911
+ sitePublished();
2912
+ return doc;
2913
+ };
2914
+ app.post('/api/documents/:id/publish', requireUser, async (c) => {
2915
+ const existing = await loadDocument(c.req.param('id'));
2916
+ const { doc, url } = await publishDocument(existing, c.get('user').id);
2917
+ // Published by hand: a publication still waiting would only repeat it.
2918
+ await schedules.cancel(existing.id, 'publish');
2919
+ await record(c, 'document.published', doc.title, { collection: doc.collection, id: doc.id, url });
2920
+ return c.json(await toJson(doc));
2291
2921
  });
2292
2922
  app.post('/api/documents/:id/unpublish', requireUser, async (c) => {
2293
2923
  const existing = await loadDocument(c.req.param('id'));
2294
- const doc = (await content.unpublish(existing.id, c.get('user').id));
2295
- if (existing.publishedAt)
2296
- ctx.onPublish?.();
2924
+ const doc = await unpublishDocument(existing, c.get('user').id);
2925
+ await schedules.cancel(existing.id, 'unpublish');
2297
2926
  await record(c, 'document.unpublished', doc.title, { collection: doc.collection, id: doc.id });
2298
- return c.json(toJson(doc));
2927
+ return c.json(await toJson(doc));
2928
+ });
2929
+ /**
2930
+ * When a document goes on the site, or comes off it, by itself. `at` null cancels. A publication
2931
+ * is checked now too, so missing fields show up when it is set, not when it fails at night.
2932
+ */
2933
+ app.put('/api/documents/:id/schedule', requireUser, async (c) => {
2934
+ const existing = await loadDocument(c.req.param('id'));
2935
+ const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']), at: z.iso.datetime({ offset: true }).nullable() }));
2936
+ const user = c.get('user');
2937
+ const label = input.action === 'publish' ? 'publish' : 'unpublish';
2938
+ if (input.at === null) {
2939
+ await schedules.cancel(existing.id, input.action);
2940
+ await record(c, `document.schedule_cancelled`, existing.title, {
2941
+ collection: existing.collection,
2942
+ id: existing.id,
2943
+ action: label,
2944
+ });
2945
+ return c.json(await toJson(existing));
2946
+ }
2947
+ const at = new Date(input.at);
2948
+ if (at.getTime() <= Date.now() + 30_000)
2949
+ throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
2950
+ const current = await schedules.forDocument(existing.id);
2951
+ if (input.action === 'publish')
2952
+ publishableData(collection(existing.collection), existing.data);
2953
+ else {
2954
+ if (!existing.publishedAt && !current.publish)
2955
+ throw new ApiError(409, 'not_published', 'La pagina non è sul sito: programma prima la pubblicazione');
2956
+ if (current.publish && current.publish.runAt >= at)
2957
+ throw new ApiError(400, 'before_publish', 'La pagina verrebbe tolta prima di essere pubblicata');
2958
+ }
2959
+ if (input.action === 'publish' && current.unpublish && current.unpublish.runAt <= at)
2960
+ throw new ApiError(400, 'after_unpublish', 'La pubblicazione verrebbe dopo il momento in cui la pagina va tolta');
2961
+ await schedules.set({ documentId: existing.id, action: input.action, runAt: at, user });
2962
+ await record(c, 'document.scheduled', existing.title, {
2963
+ collection: existing.collection,
2964
+ id: existing.id,
2965
+ action: label,
2966
+ at: at.toISOString(),
2967
+ });
2968
+ return c.json(await toJson(existing));
2299
2969
  });
2970
+ /** Why a document's draft cannot go on the site now, in words; null when it can. */
2971
+ const publishProblem = (doc) => {
2972
+ const def = ctx.config().collections.find((x) => x.name === doc.collection);
2973
+ if (!def)
2974
+ return 'La raccolta non esiste più';
2975
+ const check = validate(def, prepare(def, doc.data));
2976
+ if (check.ok)
2977
+ return null;
2978
+ const fields = [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))];
2979
+ return `Alcuni campi vanno completati${fields.length ? `: ${fields.join(', ')}` : ''}`;
2980
+ };
2981
+ /**
2982
+ * Runs the actions whose time has come, as the person who set them. A server that was off at the
2983
+ * time catches up when it starts. A publication whose fields are no longer complete fails with a
2984
+ * reason the panel shows, instead of putting a broken page online.
2985
+ */
2986
+ let schedulesRunning = false;
2987
+ const runSchedules = async (now = new Date()) => {
2988
+ if (schedulesRunning)
2989
+ return 0;
2990
+ schedulesRunning = true;
2991
+ let ran = 0;
2992
+ try {
2993
+ for (const item of await schedules.due(now)) {
2994
+ const note = (action, target, detail) => activityLog
2995
+ .record({
2996
+ userId: item.createdBy,
2997
+ userName: item.createdByName,
2998
+ action,
2999
+ target,
3000
+ detail: { ...detail, scheduled: true },
3001
+ ip: null,
3002
+ })
3003
+ .catch((error) => console.error('Materialize: activity not recorded', error));
3004
+ const existing = await content.get(item.documentId);
3005
+ if (!existing) {
3006
+ await schedules.finish(item.id, 'failed', 'La pagina non esiste più');
3007
+ continue;
3008
+ }
3009
+ try {
3010
+ if (item.action === 'publish') {
3011
+ const problem = publishProblem(existing);
3012
+ if (problem)
3013
+ throw new Error(problem);
3014
+ const { doc, url } = await publishDocument(existing, item.createdBy ?? undefined);
3015
+ await schedules.finish(item.id, 'done');
3016
+ await note('document.published', doc.title, { collection: doc.collection, id: doc.id, url });
3017
+ }
3018
+ else {
3019
+ const doc = await unpublishDocument(existing, item.createdBy ?? undefined);
3020
+ await schedules.finish(item.id, 'done');
3021
+ await note('document.unpublished', doc.title, { collection: doc.collection, id: doc.id });
3022
+ }
3023
+ ran++;
3024
+ }
3025
+ catch (error) {
3026
+ const message = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
3027
+ await schedules.finish(item.id, 'failed', message);
3028
+ await note('document.schedule_failed', existing.title, {
3029
+ collection: existing.collection,
3030
+ id: existing.id,
3031
+ action: item.action,
3032
+ message,
3033
+ });
3034
+ }
3035
+ }
3036
+ for (const release of await releases.due(now)) {
3037
+ const by = release.scheduledBy ? { id: release.scheduledBy, name: release.scheduledByName } : null;
3038
+ if (await releaseOut(release, by))
3039
+ ran++;
3040
+ }
3041
+ }
3042
+ catch (error) {
3043
+ console.error('Materialize: scheduled publishing failed', error);
3044
+ }
3045
+ finally {
3046
+ schedulesRunning = false;
3047
+ }
3048
+ return ran;
3049
+ };
3050
+ setTimeout(() => void runSchedules(), 5_000).unref?.();
3051
+ setInterval(() => void runSchedules(), 30_000).unref?.();
3052
+ /** What is coming: every pending publication and removal, soonest first. */
3053
+ app.get('/api/schedules', requireUser, async (c) => {
3054
+ const items = await schedules.pending();
3055
+ const docs = new Map();
3056
+ for (const i of items)
3057
+ if (!docs.has(i.documentId))
3058
+ docs.set(i.documentId, await content.get(i.documentId));
3059
+ return c.json({
3060
+ items: items.flatMap((i) => {
3061
+ const doc = docs.get(i.documentId);
3062
+ return doc ? [{ ...i, title: doc.title, collection: doc.collection, locale: doc.locale }] : [];
3063
+ }),
3064
+ });
3065
+ });
3066
+ /** What stops a release from going out: every document must be ready, or none goes. */
3067
+ const releaseProblems = async (release) => {
3068
+ const out = [];
3069
+ for (const item of release.items) {
3070
+ const doc = await content.get(item.documentId);
3071
+ if (!doc)
3072
+ out.push({ documentId: item.documentId, title: '', message: 'La pagina non esiste più' });
3073
+ else if (item.action === 'publish') {
3074
+ const message = publishProblem(doc);
3075
+ if (message)
3076
+ out.push({ documentId: doc.id, title: doc.title, message });
3077
+ }
3078
+ }
3079
+ if (!release.items.length)
3080
+ out.push({ documentId: '', title: '', message: 'Il rilascio è vuoto' });
3081
+ return out;
3082
+ };
3083
+ /**
3084
+ * Puts a release on the site: checked first, all or nothing. Returns whether it went out; when
3085
+ * it did not, the release is marked failed with the reason, and the panel shows it.
3086
+ */
3087
+ const releaseOut = async (release, by) => {
3088
+ const note = (action, target, detail) => activityLog
3089
+ .record({ userId: by?.id ?? null, userName: by?.name ?? '', action, target, detail, ip: null })
3090
+ .catch((error) => console.error('Materialize: activity not recorded', error));
3091
+ const problems = await releaseProblems(release);
3092
+ if (problems.length) {
3093
+ const message = problems.map((p) => (p.title ? `${p.title}: ${p.message}` : p.message)).join('; ');
3094
+ await releases.finish(release.id, 'failed', message);
3095
+ await note('release.failed', release.name, { id: release.id, message });
3096
+ return false;
3097
+ }
3098
+ const done = [];
3099
+ try {
3100
+ for (const item of release.items) {
3101
+ const doc = await content.get(item.documentId);
3102
+ if (!doc)
3103
+ continue;
3104
+ if (item.action === 'publish') {
3105
+ await publishDocument(doc, by?.id);
3106
+ await schedules.cancel(doc.id, 'publish');
3107
+ }
3108
+ else if (doc.publishedAt) {
3109
+ await unpublishDocument(doc, by?.id);
3110
+ await schedules.cancel(doc.id, 'unpublish');
3111
+ }
3112
+ done.push(doc.title);
3113
+ }
3114
+ }
3115
+ catch (error) {
3116
+ const why = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
3117
+ const message = `${why}${done.length ? ` (già uscite: ${done.join(', ')})` : ''}`;
3118
+ await releases.finish(release.id, 'failed', message);
3119
+ await note('release.failed', release.name, { id: release.id, message });
3120
+ return false;
3121
+ }
3122
+ await releases.finish(release.id, 'published');
3123
+ await note('release.published', release.name, { id: release.id, items: release.items.length });
3124
+ return true;
3125
+ };
3126
+ /** A release as the panel shows it: each document with its title, state and what stops it. */
3127
+ const releaseJson = async (release) => {
3128
+ const problems = release.status === 'published' ? [] : await releaseProblems(release);
3129
+ const items = [];
3130
+ for (const item of release.items) {
3131
+ const doc = await content.get(item.documentId);
3132
+ if (!doc)
3133
+ continue;
3134
+ items.push({
3135
+ ...item,
3136
+ title: doc.title,
3137
+ collection: doc.collection,
3138
+ locale: doc.locale,
3139
+ status: doc.status,
3140
+ problem: problems.find((p) => p.documentId === doc.id)?.message ?? null,
3141
+ });
3142
+ }
3143
+ return { ...release, items, ready: problems.length === 0 };
3144
+ };
3145
+ const loadRelease = async (id) => {
3146
+ const release = await releases.get(id);
3147
+ if (!release)
3148
+ throw new ApiError(404, 'not_found', 'Questo rilascio non esiste');
3149
+ return release;
3150
+ };
3151
+ const openRelease = async (id) => {
3152
+ const release = await loadRelease(id);
3153
+ if (release.status === 'published' || release.status === 'cancelled')
3154
+ throw new ApiError(409, 'release_closed', 'Questo rilascio è già uscito o è stato annullato');
3155
+ return release;
3156
+ };
3157
+ app.get('/api/releases', requireUser, async (c) => {
3158
+ const { active, done } = await releases.list();
3159
+ return c.json({
3160
+ releases: await Promise.all(active.map(releaseJson)),
3161
+ published: await Promise.all(done.map(releaseJson)),
3162
+ });
3163
+ });
3164
+ app.post('/api/releases', requireUser, async (c) => {
3165
+ const input = await body(c, z.object({ name: z.string().trim().min(1).max(120) }));
3166
+ const release = await releases.create(input.name, c.get('user'));
3167
+ await record(c, 'release.created', release.name, { id: release.id });
3168
+ return c.json(await releaseJson(release), 201);
3169
+ });
3170
+ app.get('/api/releases/:id', requireUser, async (c) => c.json(await releaseJson(await loadRelease(c.req.param('id')))));
3171
+ app.patch('/api/releases/:id', requireUser, async (c) => {
3172
+ const release = await openRelease(c.req.param('id'));
3173
+ const input = await body(c, z.object({ name: z.string().trim().min(1).max(120) }));
3174
+ await releases.rename(release.id, input.name);
3175
+ return c.json(await releaseJson(await loadRelease(release.id)));
3176
+ });
3177
+ /** Cancels a release: its documents stay as they are. */
3178
+ app.delete('/api/releases/:id', requireUser, async (c) => {
3179
+ const release = await openRelease(c.req.param('id'));
3180
+ await releases.finish(release.id, 'cancelled');
3181
+ await record(c, 'release.cancelled', release.name, { id: release.id });
3182
+ return c.body(null, 204);
3183
+ });
3184
+ /** Adds a document (or changes what happens to it): it leaves any other release. */
3185
+ app.put('/api/releases/:id/items/:documentId', requireUser, async (c) => {
3186
+ const release = await openRelease(c.req.param('id'));
3187
+ const doc = await loadDocument(c.req.param('documentId'));
3188
+ const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']).default('publish') }));
3189
+ if (input.action === 'unpublish' && !doc.publishedAt)
3190
+ throw new ApiError(409, 'not_published', 'La pagina non è sul sito: non c’è niente da togliere');
3191
+ await releases.put(release.id, doc.id, input.action);
3192
+ await record(c, 'release.item_added', doc.title, { id: release.id, release: release.name, action: input.action });
3193
+ return c.json(await releaseJson(await loadRelease(release.id)));
3194
+ });
3195
+ app.delete('/api/releases/:id/items/:documentId', requireUser, async (c) => {
3196
+ const release = await openRelease(c.req.param('id'));
3197
+ await releases.remove(release.id, c.req.param('documentId'));
3198
+ return c.json(await releaseJson(await loadRelease(release.id)));
3199
+ });
3200
+ /** Everything in the release goes on the site now, or nothing does (the reasons come back). */
3201
+ app.post('/api/releases/:id/publish', requireUser, async (c) => {
3202
+ const release = await openRelease(c.req.param('id'));
3203
+ const problems = await releaseProblems(release);
3204
+ if (problems.length)
3205
+ return c.json({
3206
+ error: {
3207
+ code: 'release_not_ready',
3208
+ message: 'Alcune pagine non sono pronte: non è uscito niente',
3209
+ problems,
3210
+ },
3211
+ }, 422);
3212
+ const user = c.get('user');
3213
+ const ok = await releaseOut(release, user);
3214
+ const after = await loadRelease(release.id);
3215
+ if (!ok)
3216
+ throw new ApiError(422, 'release_failed', after.error ?? 'Il rilascio non è uscito');
3217
+ return c.json(await releaseJson(after));
3218
+ });
3219
+ /** When the release goes out by itself; at null: only by hand. Checked now, and again at that moment. */
3220
+ app.put('/api/releases/:id/schedule', requireUser, async (c) => {
3221
+ const release = await openRelease(c.req.param('id'));
3222
+ const input = await body(c, z.object({ at: z.iso.datetime({ offset: true }).nullable() }));
3223
+ const user = c.get('user');
3224
+ if (input.at === null) {
3225
+ await releases.schedule(release.id, null, null);
3226
+ await record(c, 'release.schedule_cancelled', release.name, { id: release.id });
3227
+ return c.json(await releaseJson(await loadRelease(release.id)));
3228
+ }
3229
+ const at = new Date(input.at);
3230
+ if (at.getTime() <= Date.now() + 30_000)
3231
+ throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
3232
+ if (!release.items.length)
3233
+ throw new ApiError(400, 'empty_release', 'Aggiungi prima qualcosa al rilascio');
3234
+ await releases.schedule(release.id, at, user);
3235
+ await record(c, 'release.scheduled', release.name, { id: release.id, at: at.toISOString() });
3236
+ return c.json(await releaseJson(await loadRelease(release.id)));
3237
+ });
3238
+ // ---------- shared preview links ----------
3239
+ const previewJson = (c, link) => {
3240
+ const path = `/_materialize/share/${link.token}`;
3241
+ return { ...link, path, url: new URL(path, publicOrigin(c)).toString() };
3242
+ };
3243
+ /** The links that still work for a document, newest first. */
3244
+ app.get('/api/documents/:id/previews', requireUser, async (c) => {
3245
+ const existing = await loadDocument(c.req.param('id'));
3246
+ return c.json({ items: (await previewLinks.forDocument(existing.id)).map((l) => previewJson(c, l)) });
3247
+ });
3248
+ /**
3249
+ * A secret address that shows the latest saved draft with the site's own design, to people
3250
+ * without an account: a client, a colleague. `days` null: until someone revokes it.
3251
+ */
3252
+ app.post('/api/documents/:id/previews', requireUser, async (c) => {
3253
+ const existing = await loadDocument(c.req.param('id'));
3254
+ const input = await body(c, z.object({ days: z.number().int().min(1).max(365).nullable() }));
3255
+ if (!ctx.preview)
3256
+ throw new ApiError(409, 'no_site', 'Questo sito non ha pagine da mostrare in anteprima');
3257
+ if (!collection(existing.collection).url)
3258
+ throw new ApiError(400, 'no_page', 'Questo contenuto non ha una pagina sul sito');
3259
+ const expiresAt = input.days === null ? null : new Date(Date.now() + input.days * 86_400_000);
3260
+ const link = await previewLinks.create({ documentId: existing.id, expiresAt, user: c.get('user') });
3261
+ await record(c, 'document.preview_shared', existing.title, {
3262
+ collection: existing.collection,
3263
+ id: existing.id,
3264
+ expiresAt: expiresAt?.toISOString() ?? null,
3265
+ });
3266
+ return c.json(previewJson(c, link), 201);
3267
+ });
3268
+ app.delete('/api/previews/:id', requireUser, async (c) => {
3269
+ const link = await previewLinks.get(c.req.param('id'));
3270
+ if (!link)
3271
+ throw new ApiError(404, 'not_found', 'Link non trovato');
3272
+ const doc = await content.get(link.documentId);
3273
+ await previewLinks.revoke(link.id);
3274
+ await record(c, 'document.preview_revoked', doc?.title ?? '', { id: link.documentId });
3275
+ return c.body(null, 204);
3276
+ });
3277
+ void previewLinks.prune().catch(() => { });
2300
3278
  app.delete('/api/documents/:id', requireUser, requireAdmin, async (c) => {
2301
3279
  const existing = await loadDocument(c.req.param('id'));
2302
3280
  await content.remove(existing.id);
3281
+ await schedules.removeForDocument(existing.id);
3282
+ await releases.removeDocument(existing.id);
3283
+ await previewLinks.removeForDocument(existing.id);
2303
3284
  if (existing.publishedAt)
2304
- ctx.onPublish?.();
3285
+ sitePublished();
2305
3286
  await record(c, 'document.deleted', existing.title, { collection: existing.collection });
2306
3287
  return c.body(null, 204);
2307
3288
  });
@@ -2321,7 +3302,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2321
3302
  if (!doc)
2322
3303
  throw new ApiError(404, 'not_found', 'This version does not exist');
2323
3304
  await record(c, 'document.restored', doc.title, { collection: doc.collection, id: doc.id });
2324
- return c.json(toJson(doc));
3305
+ return c.json(await toJson(doc));
2325
3306
  });
2326
3307
  // ---------- languages and translations ----------
2327
3308
  /** How many documents each language has: removing a language hides them from the site. */
@@ -2346,7 +3327,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2346
3327
  const value = { default: input.default, available: [...new Set(input.available)] };
2347
3328
  await settings.set('locales', value, c.get('user').id);
2348
3329
  languages = value;
2349
- ctx.onPublish?.();
3330
+ sitePublished();
2350
3331
  await record(c, 'settings.languages', value.available.join(', '));
2351
3332
  return c.json({ locales: ctx.config().locales, documents: await countsByLocale() });
2352
3333
  });
@@ -2368,7 +3349,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2368
3349
  slots = only(slots);
2369
3350
  if (!slots.length)
2370
3351
  return;
2371
- const translated = await translateTexts(ai, slots.map((s) => s.text), from, to).catch(aiFailed);
3352
+ const translated = await translateTexts(ai, slots.map((s) => s.text), from, to, undefined, await voiceText('style')).catch(aiFailed);
2372
3353
  for (const [i, slot] of slots.entries())
2373
3354
  slot.set(translated[i]);
2374
3355
  };
@@ -2409,7 +3390,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2409
3390
  sourceId: original?.id ?? doc.id,
2410
3391
  }));
2411
3392
  await record(c, 'document.translated', created.title, { locale: lang, id: created.id, ai: input.translate });
2412
- return c.json(toJson(created), 201);
3393
+ return c.json(await toJson(created), 201);
2413
3394
  });
2414
3395
  /**
2415
3396
  * Brings a translation in line with its original: the original's structure and design, with the
@@ -2445,7 +3426,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2445
3426
  const own = nodeSlots(walk(data[name]));
2446
3427
  if (!own.length)
2447
3428
  continue;
2448
- const translated = await translateTexts(ai, own.map((s) => s.text), original.locale, doc.locale).catch(aiFailed);
3429
+ const translated = await translateTexts(ai, own.map((s) => s.text), original.locale, doc.locale, undefined, await voiceText('style')).catch(aiFailed);
2449
3430
  for (const [i, slot] of own.entries())
2450
3431
  slot.set(translated[i]);
2451
3432
  }
@@ -2455,7 +3436,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2455
3436
  await guardDesign(c.get('user'), doc.data[name], data[name]);
2456
3437
  const draft = draftData(def, data);
2457
3438
  const saved = await slugGuard(() => content.update(doc.id, { ...draft, userId: c.get('user').id }));
2458
- return c.json({ document: toJson(saved), added: fresh.size });
3439
+ return c.json({ document: await toJson(saved), added: fresh.size });
2459
3440
  });
2460
3441
  // ---------- importing a WordPress site ----------
2461
3442
  /** Some hosts turn away requests that do not say who they are. */
@@ -2551,6 +3532,60 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2551
3532
  ])),
2552
3533
  });
2553
3534
  });
3535
+ /** The caller's credentials, for tools that go through the API as them. */
3536
+ const credentialsOf = (c) => {
3537
+ const authorization = c.req.header('authorization');
3538
+ const cookie = c.req.header('cookie');
3539
+ return authorization ? { authorization } : cookie ? { cookie } : {};
3540
+ };
3541
+ /**
3542
+ * An imported page laid out again by the AI with the section library, in its draft. The texts
3543
+ * and images must stay the same (checked after): otherwise the page is left as it was.
3544
+ */
3545
+ const relayoutPage = async (ai, id, user, credentials) => {
3546
+ const doc = await content.get(id);
3547
+ if (!doc)
3548
+ return 'la pagina non c’è più';
3549
+ const def = collection(doc.collection);
3550
+ const field = Object.entries(def.fields).find(([, f]) => f.kind === 'page')?.[0];
3551
+ const before = field && Array.isArray(doc.data[field]) ? doc.data[field] : null;
3552
+ if (!field || !before?.length)
3553
+ return 'la pagina è vuota';
3554
+ const page = { nodes: before };
3555
+ const call = apiCaller(app.fetch, credentials);
3556
+ const getSchema = () => call('GET', '/api/schema');
3557
+ const tools = [
3558
+ ...contentTools(call, getSchema).filter((t) => ['list_library', 'get_theme'].includes(t.name)),
3559
+ ...pageTools({
3560
+ withId: false,
3561
+ brand: async () => ctx.config().site.name,
3562
+ source: async () => ({
3563
+ load: async () => page.nodes,
3564
+ save: async (nodes) => {
3565
+ page.nodes = nodes;
3566
+ },
3567
+ }),
3568
+ }),
3569
+ ];
3570
+ const system = `You redesign one page of the website "${ctx.config().site.name}", just imported from WordPress into Materialize. Its content arrived as a plain column of texts, images and buttons, one section per heading. Rebuild it so it looks designed, with the section library.
3571
+
3572
+ How: read the page (get_page) and the library (list_library); replace the plain sections with library sections (edit_page: add_section, then fill them; remove what you replace and the library sample nodes you do not use); read the page again at the end and fix what is left.
3573
+
3574
+ Rules:
3575
+ - Keep every text word for word: you may split it across sections, change its style (title, body, eyebrow) or turn a list into cards, but do not rewrite, shorten or translate it.
3576
+ - Keep every image (same src and alt) and every button with its link.
3577
+ - Add no text of your own: no new headings, slogans, labels or facts. Library sample texts and pictures you do not replace with the page's own content must be removed.
3578
+ - Do not add a menu or a footer: the site has its own.
3579
+ - Keep it simple: a clear opening, then sections that follow the page's order.
3580
+ When done, reply with one short sentence.`;
3581
+ await runTurn(ai, { provider: ai.provider, messages: [] }, { system, text: `Lay out the page "${doc.title}".`, tools, onEvent: () => { }, maxSteps: 30 });
3582
+ const problem = relayoutProblem(before, page.nodes);
3583
+ if (problem)
3584
+ return problem;
3585
+ const draft = draftData(def, { ...doc.data, [field]: restoreAlts(before, page.nodes).map(plain) });
3586
+ await content.update(doc.id, { ...draft, userId: user.id });
3587
+ return null;
3588
+ };
2554
3589
  app.post('/api/import/wordpress/run', requireUser, requireSession, requireAdmin, async (c) => {
2555
3590
  const target = z.object({ collection: z.string(), fields: z.record(z.string(), z.string()) }).nullable();
2556
3591
  const input = await body(c, z.object({
@@ -2563,9 +3598,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2563
3598
  locale: z.string().optional(),
2564
3599
  categories: z.record(z.string(), z.string()).optional(),
2565
3600
  fallbackCategory: z.string().optional(),
3601
+ aiLayout: z.boolean().optional(),
2566
3602
  }),
2567
3603
  }));
2568
3604
  const held = wpSites.get(input.token);
3605
+ const ai = input.plan.aiLayout ? await readyAi() : null;
2569
3606
  if (!held)
2570
3607
  throw new ApiError(409, 'import_expired', 'Read the site again');
2571
3608
  const lang = locale(input.plan.locale);
@@ -2584,6 +3621,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2584
3621
  problems: [],
2585
3622
  media: { downloaded: 0, failed: 0, reasons: {} },
2586
3623
  redirects: 0,
3624
+ relaid: 0,
2587
3625
  at: Date.now(),
2588
3626
  };
2589
3627
  wpJobs.set(id, job);
@@ -2674,18 +3712,24 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2674
3712
  return list.length - before;
2675
3713
  },
2676
3714
  onProgress: (p) => Object.assign(job, p),
3715
+ relayout: ai ? (doc) => relayoutPage(ai, doc.id, user, credentialsOf(c)) : undefined,
2677
3716
  };
2678
3717
  void runImport(held.site, plan, deps)
2679
3718
  .then(async (result) => {
2680
3719
  await settings.set('imports:media', known, user.id);
2681
3720
  if (result.created.some((d) => d.published) || result.redirects)
2682
- ctx.onPublish?.();
3721
+ sitePublished();
2683
3722
  await activityLog.record({
2684
3723
  userId: user.id,
2685
3724
  userName: user.name,
2686
3725
  action: 'import.wordpress',
2687
3726
  target: held.site.name,
2688
- detail: { created: result.created.length, media: result.media.downloaded, redirects: result.redirects },
3727
+ detail: {
3728
+ created: result.created.length,
3729
+ media: result.media.downloaded,
3730
+ redirects: result.redirects,
3731
+ ...(result.relaid ? { relaid: result.relaid } : {}),
3732
+ },
2689
3733
  ip: null,
2690
3734
  });
2691
3735
  })
@@ -2866,5 +3910,6 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2866
3910
  throw new ApiError(404, 'not_found', 'This page is not published');
2867
3911
  return c.json(publicDocument(doc));
2868
3912
  });
2869
- return app;
3913
+ // runSchedules: for the tests, which cannot wait for the clock.
3914
+ return Object.assign(app, { runSchedules });
2870
3915
  }