@materializecms/server 0.5.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
@@ -13,7 +13,8 @@ import { expandComponents, expandGlobals, formDefinition, normalizeTheme, sameDe
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, createPreviewLinkStore, createScheduleStore, 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,9 +25,10 @@ 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';
31
33
  import { fingerprintOf, readSubmission, removeFiles, saveFiles, sendWebhook, spamReason, } from './forms.js';
32
34
  import { checkSenderDomain, explainMailError, isConfigured, publicMail, sendMail, submissionText, } from './mail.js';
@@ -36,10 +38,11 @@ import { DEFAULT_OFFSITE, newRecoveryKey, runOffsite, testOffsite, } from './off
36
38
  import { hashPassword, verifyPassword } from './password.js';
37
39
  import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
38
40
  import { statusPage } from './status-page.js';
39
- import { apiCaller, assistantTools } from './tools.js';
41
+ import { studioSystem, studioTools } from './studio.js';
42
+ import { apiCaller, assistantTools, contentTools, pageTools } from './tools.js';
40
43
  import { newSecret, otpauthUri, recoveryCodes, verifyCode } from './totp.js';
41
44
  import { readRest, readWxr, WordPressError } from './wordpress.js';
42
- import { fold, optionsOf, runImport, sourcesFor, suggest, } from './wpimport.js';
45
+ import { fold, optionsOf, relayoutProblem, restoreAlts, runImport, sourcesFor, suggest, } from './wpimport.js';
43
46
  /** The installed version, from this package's package.json (same place in the sources and in the release). */
44
47
  export const VERSION = createRequire(import.meta.url)('../package.json').version;
45
48
  const SESSION_COOKIE = 'mz_session';
@@ -190,6 +193,7 @@ export function createApp(base) {
190
193
  const comments = createCommentStore(ctx.database.db);
191
194
  const activityLog = createActivityStore(ctx.database.db);
192
195
  const schedules = createScheduleStore(ctx.database.db);
196
+ const releases = createReleaseStore(ctx.database.db);
193
197
  const previewLinks = createPreviewLinkStore(ctx.database.db);
194
198
  const backups = createBackups(ctx.database, () => ctx.config().media.dir);
195
199
  const backupSettings = async () => ({
@@ -461,6 +465,12 @@ export function createApp(base) {
461
465
  issues: check && !check.ok ? check.issues : [],
462
466
  /** When it goes on or off the site by itself, and the last scheduled action that failed. */
463
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),
464
474
  };
465
475
  };
466
476
  /**
@@ -888,7 +898,7 @@ export function createApp(base) {
888
898
  const ACTIVITY_GROUPS = {
889
899
  access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.', 'password.'],
890
900
  people: ['user.'],
891
- content: ['document.', 'media.', 'message.'],
901
+ content: ['document.', 'release.', 'site.', 'media.', 'message.'],
892
902
  settings: ['settings.', 'backup.', 'update.'],
893
903
  };
894
904
  app.get('/api/activity', requireUser, requireAdmin, async (c) => {
@@ -1826,6 +1836,203 @@ export function createApp(base) {
1826
1836
  await complete(ai, { ...probe, fast: true }).catch(aiFailed);
1827
1837
  return c.json({ ok: true, reply });
1828
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
+ });
1829
2036
  const TEXT_ACTIONS = {
1830
2037
  rewrite: 'Rewrite it so it reads better: same meaning, similar length.',
1831
2038
  shorten: 'Make it shorter, keeping what matters most.',
@@ -1854,8 +2061,12 @@ export function createApp(base) {
1854
2061
  if (!allowAi(c.get('user').id))
1855
2062
  throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
1856
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');
1857
2066
  const lang = input.locale ?? ctx.config().locales.default;
1858
- 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}`}`;
1859
2070
  if (input.action === 'alt') {
1860
2071
  const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
1861
2072
  const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
@@ -1948,8 +2159,12 @@ export function createApp(base) {
1948
2159
  const chatId = input.chatId ?? crypto.randomUUID();
1949
2160
  const key = `${user.id}:${chatId}`;
1950
2161
  const entry = chats.get(key);
1951
- const conv = entry && entry.conv.provider === ai.provider ? entry.conv : { provider: ai.provider, messages: [] };
1952
- 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 });
1953
2168
  const before = input.nodes;
1954
2169
  const page = { nodes: before };
1955
2170
  const auth = {};
@@ -1964,7 +2179,7 @@ export function createApp(base) {
1964
2179
  // The instructions stay the same for the whole conversation: the model's earlier reasoning is
1965
2180
  // tied to them (a change is refused) and they are read from the prompt cache. What changes
1966
2181
  // from turn to turn, the page and what is selected, goes with each message instead.
1967
- 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.
1968
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.
1969
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.
1970
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.
@@ -1982,7 +2197,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
1982
2197
  tools,
1983
2198
  onEvent: (e) => void send(e),
1984
2199
  });
1985
- chats.set(key, { conv, at: Date.now() });
2200
+ chats.set(key, { conv, at: Date.now(), voice });
1986
2201
  if (page.nodes !== before) {
1987
2202
  if (limited && !sameDesign(before, page.nodes))
1988
2203
  await send({
@@ -2005,6 +2220,195 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2005
2220
  }
2006
2221
  });
2007
2222
  });
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
+ });
2008
2412
  // ---------- forms ----------
2009
2413
  /**
2010
2414
  * Every form on the published site, by id, as the server checks it: canvas pages with their
@@ -2563,6 +2967,17 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2563
2967
  });
2564
2968
  return c.json(await toJson(existing));
2565
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
+ };
2566
2981
  /**
2567
2982
  * Runs the actions whose time has come, as the person who set them. A server that was off at the
2568
2983
  * time catches up when it starts. A publication whose fields are no longer complete fails with a
@@ -2593,14 +3008,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2593
3008
  }
2594
3009
  try {
2595
3010
  if (item.action === 'publish') {
2596
- const def = ctx.config().collections.find((x) => x.name === existing.collection);
2597
- const check = def ? validate(def, prepare(def, existing.data)) : null;
2598
- if (!def || !check?.ok) {
2599
- const fields = def && check && !check.ok
2600
- ? [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))].join(', ')
2601
- : '';
2602
- throw new Error(def ? `Alcuni campi vanno completati${fields ? `: ${fields}` : ''}` : 'La raccolta non esiste più');
2603
- }
3011
+ const problem = publishProblem(existing);
3012
+ if (problem)
3013
+ throw new Error(problem);
2604
3014
  const { doc, url } = await publishDocument(existing, item.createdBy ?? undefined);
2605
3015
  await schedules.finish(item.id, 'done');
2606
3016
  await note('document.published', doc.title, { collection: doc.collection, id: doc.id, url });
@@ -2623,6 +3033,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2623
3033
  });
2624
3034
  }
2625
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
+ }
2626
3041
  }
2627
3042
  catch (error) {
2628
3043
  console.error('Materialize: scheduled publishing failed', error);
@@ -2648,6 +3063,178 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2648
3063
  }),
2649
3064
  });
2650
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
+ });
2651
3238
  // ---------- shared preview links ----------
2652
3239
  const previewJson = (c, link) => {
2653
3240
  const path = `/_materialize/share/${link.token}`;
@@ -2692,6 +3279,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2692
3279
  const existing = await loadDocument(c.req.param('id'));
2693
3280
  await content.remove(existing.id);
2694
3281
  await schedules.removeForDocument(existing.id);
3282
+ await releases.removeDocument(existing.id);
2695
3283
  await previewLinks.removeForDocument(existing.id);
2696
3284
  if (existing.publishedAt)
2697
3285
  sitePublished();
@@ -2761,7 +3349,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2761
3349
  slots = only(slots);
2762
3350
  if (!slots.length)
2763
3351
  return;
2764
- 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);
2765
3353
  for (const [i, slot] of slots.entries())
2766
3354
  slot.set(translated[i]);
2767
3355
  };
@@ -2838,7 +3426,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2838
3426
  const own = nodeSlots(walk(data[name]));
2839
3427
  if (!own.length)
2840
3428
  continue;
2841
- 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);
2842
3430
  for (const [i, slot] of own.entries())
2843
3431
  slot.set(translated[i]);
2844
3432
  }
@@ -2944,6 +3532,60 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2944
3532
  ])),
2945
3533
  });
2946
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
+ };
2947
3589
  app.post('/api/import/wordpress/run', requireUser, requireSession, requireAdmin, async (c) => {
2948
3590
  const target = z.object({ collection: z.string(), fields: z.record(z.string(), z.string()) }).nullable();
2949
3591
  const input = await body(c, z.object({
@@ -2956,9 +3598,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2956
3598
  locale: z.string().optional(),
2957
3599
  categories: z.record(z.string(), z.string()).optional(),
2958
3600
  fallbackCategory: z.string().optional(),
3601
+ aiLayout: z.boolean().optional(),
2959
3602
  }),
2960
3603
  }));
2961
3604
  const held = wpSites.get(input.token);
3605
+ const ai = input.plan.aiLayout ? await readyAi() : null;
2962
3606
  if (!held)
2963
3607
  throw new ApiError(409, 'import_expired', 'Read the site again');
2964
3608
  const lang = locale(input.plan.locale);
@@ -2977,6 +3621,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
2977
3621
  problems: [],
2978
3622
  media: { downloaded: 0, failed: 0, reasons: {} },
2979
3623
  redirects: 0,
3624
+ relaid: 0,
2980
3625
  at: Date.now(),
2981
3626
  };
2982
3627
  wpJobs.set(id, job);
@@ -3067,6 +3712,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3067
3712
  return list.length - before;
3068
3713
  },
3069
3714
  onProgress: (p) => Object.assign(job, p),
3715
+ relayout: ai ? (doc) => relayoutPage(ai, doc.id, user, credentialsOf(c)) : undefined,
3070
3716
  };
3071
3717
  void runImport(held.site, plan, deps)
3072
3718
  .then(async (result) => {
@@ -3078,7 +3724,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
3078
3724
  userName: user.name,
3079
3725
  action: 'import.wordpress',
3080
3726
  target: held.site.name,
3081
- 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
+ },
3082
3733
  ip: null,
3083
3734
  });
3084
3735
  })