@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/CHANGELOG.md +27 -0
- package/package.json +4 -4
- package/src/ai.d.ts +6 -1
- package/src/ai.js +11 -3
- package/src/app.d.ts +3 -1
- package/src/app.js +1119 -74
- package/src/brief.d.ts +43 -0
- package/src/brief.js +172 -0
- package/src/forms.d.ts +47 -0
- package/src/forms.js +153 -0
- package/src/studio.d.ts +13 -0
- package/src/studio.js +75 -0
- package/src/tools.d.ts +1 -0
- package/src/tools.js +89 -2
- package/src/wordpress.d.ts +2 -0
- package/src/wordpress.js +22 -5
- package/src/wpimport.d.ts +19 -1
- package/src/wpimport.js +112 -3
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 {
|
|
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 {
|
|
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
|
-
/**
|
|
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ò
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1929
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
2005
|
-
|
|
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
|
|
2008
|
-
subject: `Nuovo messaggio dal sito${
|
|
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
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2271
|
-
|
|
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,
|
|
2902
|
+
await settings.set('redirects', next, userId);
|
|
2286
2903
|
}
|
|
2287
|
-
const doc = (await content.publish(existing.id,
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
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 =
|
|
2295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
3913
|
+
// runSchedules: for the tests, which cannot wait for the clock.
|
|
3914
|
+
return Object.assign(app, { runSchedules });
|
|
2870
3915
|
}
|