@materializecms/server 0.5.0 → 0.7.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 +21 -0
- package/README.md +2 -0
- package/package.json +4 -4
- package/src/ai.d.ts +6 -1
- package/src/ai.js +11 -3
- package/src/app.d.ts +1 -0
- package/src/app.js +1190 -26
- package/src/brief.d.ts +43 -0
- package/src/brief.js +172 -0
- package/src/mail.d.ts +2 -0
- package/src/newsletter.d.ts +50 -0
- package/src/newsletter.js +192 -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 +57 -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 { expandComponents, expandGlobals, formDefinition, normalizeTheme, sameDesign, walk, } from '@materializecms/core/page';
|
|
13
|
-
import { addMove, normalizePath } from '@materializecms/core/seo';
|
|
12
|
+
import { expandComponents, expandGlobals, formDefinition, normalizeTheme, richTextHtml, sameDesign, walk, } from '@materializecms/core/page';
|
|
13
|
+
import { addMove, feedCollections, 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, createNewsletterStore, 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,22 +25,25 @@ 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';
|
|
33
35
|
import { createMcpServer } from './mcp.js';
|
|
34
36
|
import { createMediaFiles, fileBody, MAX_UPLOAD, MediaError, mediaUrl } from './media.js';
|
|
37
|
+
import { confirmEmail, langOf, linkSignature, newsletterHtml, newsletterText, PIXEL, parseSubscribersCsv, validLink, } from './newsletter.js';
|
|
35
38
|
import { DEFAULT_OFFSITE, newRecoveryKey, runOffsite, testOffsite, } from './offsite.js';
|
|
36
39
|
import { hashPassword, verifyPassword } from './password.js';
|
|
37
40
|
import { fetchPublicFile, RemoteError, remoteFetch } from './remote.js';
|
|
38
41
|
import { statusPage } from './status-page.js';
|
|
39
|
-
import {
|
|
42
|
+
import { studioSystem, studioTools } from './studio.js';
|
|
43
|
+
import { apiCaller, assistantTools, contentTools, pageTools } from './tools.js';
|
|
40
44
|
import { newSecret, otpauthUri, recoveryCodes, verifyCode } from './totp.js';
|
|
41
45
|
import { readRest, readWxr, WordPressError } from './wordpress.js';
|
|
42
|
-
import { fold, optionsOf, runImport, sourcesFor, suggest, } from './wpimport.js';
|
|
46
|
+
import { fold, optionsOf, relayoutProblem, restoreAlts, runImport, sourcesFor, suggest, } from './wpimport.js';
|
|
43
47
|
/** The installed version, from this package's package.json (same place in the sources and in the release). */
|
|
44
48
|
export const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
45
49
|
const SESSION_COOKIE = 'mz_session';
|
|
@@ -190,6 +194,8 @@ export function createApp(base) {
|
|
|
190
194
|
const comments = createCommentStore(ctx.database.db);
|
|
191
195
|
const activityLog = createActivityStore(ctx.database.db);
|
|
192
196
|
const schedules = createScheduleStore(ctx.database.db);
|
|
197
|
+
const releases = createReleaseStore(ctx.database.db);
|
|
198
|
+
const newsletter = createNewsletterStore(ctx.database.db);
|
|
193
199
|
const previewLinks = createPreviewLinkStore(ctx.database.db);
|
|
194
200
|
const backups = createBackups(ctx.database, () => ctx.config().media.dir);
|
|
195
201
|
const backupSettings = async () => ({
|
|
@@ -461,6 +467,12 @@ export function createApp(base) {
|
|
|
461
467
|
issues: check && !check.ok ? check.issues : [],
|
|
462
468
|
/** When it goes on or off the site by itself, and the last scheduled action that failed. */
|
|
463
469
|
schedule: await schedules.forDocument(doc.id),
|
|
470
|
+
/** The release it goes out with, if any. */
|
|
471
|
+
release: await releases
|
|
472
|
+
.forDocument(doc.id)
|
|
473
|
+
.then((r) => r
|
|
474
|
+
? { id: r.id, name: r.name, runAt: r.runAt, action: r.items.find((i) => i.documentId === doc.id)?.action }
|
|
475
|
+
: null),
|
|
464
476
|
};
|
|
465
477
|
};
|
|
466
478
|
/**
|
|
@@ -494,7 +506,9 @@ export function createApp(base) {
|
|
|
494
506
|
app.use('*', secureHeaders());
|
|
495
507
|
// Requests with a bearer token carry no cookie, so a forged form cannot ride on them.
|
|
496
508
|
const checkOrigin = csrf();
|
|
497
|
-
app.use('/api/*', (c, next) =>
|
|
509
|
+
app.use('/api/*', (c, next) =>
|
|
510
|
+
// Leaving the newsletter with one click from the email app comes with no Origin: the token is the proof.
|
|
511
|
+
bearerOf(c) || c.req.path.startsWith('/api/newsletter/unsubscribe/') ? next() : checkOrigin(c, next));
|
|
498
512
|
// Answers change with every edit: browsers must not reuse them.
|
|
499
513
|
app.use('/api/*', async (c, next) => {
|
|
500
514
|
await next();
|
|
@@ -888,7 +902,7 @@ export function createApp(base) {
|
|
|
888
902
|
const ACTIVITY_GROUPS = {
|
|
889
903
|
access: ['login.', 'logout', 'session.', 'twofactor.', 'token.', 'security.', 'password.'],
|
|
890
904
|
people: ['user.'],
|
|
891
|
-
content: ['document.', 'media.', 'message.'],
|
|
905
|
+
content: ['document.', 'release.', 'site.', 'newsletter.', 'import.', 'media.', 'message.'],
|
|
892
906
|
settings: ['settings.', 'backup.', 'update.'],
|
|
893
907
|
};
|
|
894
908
|
app.get('/api/activity', requireUser, requireAdmin, async (c) => {
|
|
@@ -1347,7 +1361,11 @@ export function createApp(base) {
|
|
|
1347
1361
|
return c.json({ ok: true });
|
|
1348
1362
|
});
|
|
1349
1363
|
app.patch('/api/auth/me', requireUser, async (c) => {
|
|
1350
|
-
const input = await body(c, z.object({
|
|
1364
|
+
const input = await body(c, z.object({
|
|
1365
|
+
name: z.string().trim().min(1).max(80).optional(),
|
|
1366
|
+
/** The panel's language; null: follow the browser. */
|
|
1367
|
+
locale: z.enum(['it', 'en']).nullable().optional(),
|
|
1368
|
+
}));
|
|
1351
1369
|
return c.json({ user: await auth.updateUser(c.get('user').id, input) });
|
|
1352
1370
|
});
|
|
1353
1371
|
/** A changed sign-in address: the old one is told, in case it was not its owner who changed it. */
|
|
@@ -1826,6 +1844,203 @@ export function createApp(base) {
|
|
|
1826
1844
|
await complete(ai, { ...probe, fast: true }).catch(aiFailed);
|
|
1827
1845
|
return c.json({ ok: true, reply });
|
|
1828
1846
|
});
|
|
1847
|
+
// ---------- brand voice ----------
|
|
1848
|
+
/** `style` when the AI changes a text someone wrote, `full` when it writes one (see voicePrompt). */
|
|
1849
|
+
const voiceText = async (use = 'full') => voicePrompt(await settings.get('voice'), use);
|
|
1850
|
+
const voiceSchema = z.object({
|
|
1851
|
+
about: z.string().trim().max(1000).optional(),
|
|
1852
|
+
tones: z.array(z.enum(VOICE_TONES)).max(3).optional(),
|
|
1853
|
+
address: z.enum(['tu', 'voi', 'lei']).nullable().optional(),
|
|
1854
|
+
prefer: z.string().trim().max(1000).optional(),
|
|
1855
|
+
avoid: z.string().trim().max(1000).optional(),
|
|
1856
|
+
rules: z.string().trim().max(1500).optional(),
|
|
1857
|
+
example: z.string().trim().max(3000).optional(),
|
|
1858
|
+
});
|
|
1859
|
+
app.get('/api/voice', requireUser, async (c) => c.json({ voice: (await settings.get('voice')) ?? {} }));
|
|
1860
|
+
app.put('/api/voice', requireUser, requireAdmin, async (c) => {
|
|
1861
|
+
const input = await body(c, z.object({ voice: voiceSchema }));
|
|
1862
|
+
await settings.set('voice', input.voice, c.get('user').id);
|
|
1863
|
+
await record(c, 'settings.voice');
|
|
1864
|
+
return c.json({ voice: input.voice });
|
|
1865
|
+
});
|
|
1866
|
+
/**
|
|
1867
|
+
* A brand voice read from the texts already on the site, to start from: nothing is saved, the
|
|
1868
|
+
* panel puts it in the form to check.
|
|
1869
|
+
*/
|
|
1870
|
+
app.post('/api/voice/suggest', requireUser, requireAdmin, async (c) => {
|
|
1871
|
+
if (!allowAi(c.get('user').id))
|
|
1872
|
+
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
1873
|
+
const ai = await readyAi();
|
|
1874
|
+
const config = ctx.config();
|
|
1875
|
+
const texts = [];
|
|
1876
|
+
let size = 0;
|
|
1877
|
+
for (const def of config.collections) {
|
|
1878
|
+
for (const doc of await content.published(def.name, config.locales.default)) {
|
|
1879
|
+
for (const slot of documentSlots(def.fields, doc.publishedData ?? {})) {
|
|
1880
|
+
const t = slot.text.replace(/<[^>]+>/g, '').trim();
|
|
1881
|
+
if (t.length < 20 || size > 14_000)
|
|
1882
|
+
continue;
|
|
1883
|
+
texts.push(t);
|
|
1884
|
+
size += t.length;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
if (size < 300)
|
|
1889
|
+
throw new ApiError(400, 'too_little_text', 'Il sito ha ancora pochi testi pubblicati: scrivi la voce a mano');
|
|
1890
|
+
const reply = await complete(ai, {
|
|
1891
|
+
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.',
|
|
1892
|
+
prompt: `Read these texts of the website "${config.site.name}" and describe how it speaks, writing in the language of the texts.
|
|
1893
|
+
Reply with this JSON object:
|
|
1894
|
+
{"about": "who the brand is and who it writes for, one or two sentences",
|
|
1895
|
+
"tones": [up to three of ${JSON.stringify(VOICE_TONES)}],
|
|
1896
|
+
"address": "tu" | "voi" | "lei" | null (how readers are addressed),
|
|
1897
|
+
"prefer": "words and phrases typical of these texts, comma separated",
|
|
1898
|
+
"avoid": "words that would sound wrong for this brand, comma separated",
|
|
1899
|
+
"rules": "two or three rules of style seen in the texts, as short sentences",
|
|
1900
|
+
"example": "a short paragraph of new text in this voice, about the brand"}
|
|
1901
|
+
|
|
1902
|
+
<texts>
|
|
1903
|
+
${texts.join('\n---\n')}
|
|
1904
|
+
</texts>`,
|
|
1905
|
+
}).catch(aiFailed);
|
|
1906
|
+
let raw = null;
|
|
1907
|
+
try {
|
|
1908
|
+
raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
|
|
1909
|
+
}
|
|
1910
|
+
catch { }
|
|
1911
|
+
const parsed = voiceSchema.safeParse(raw && {
|
|
1912
|
+
...raw,
|
|
1913
|
+
tones: Array.isArray(raw.tones)
|
|
1914
|
+
? raw.tones.filter((t) => VOICE_TONES.includes(t)).slice(0, 3)
|
|
1915
|
+
: [],
|
|
1916
|
+
address: ['tu', 'voi', 'lei'].includes(raw.address) ? raw.address : null,
|
|
1917
|
+
});
|
|
1918
|
+
if (!parsed.success)
|
|
1919
|
+
throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
|
|
1920
|
+
return c.json({ voice: parsed.data, texts: texts.length });
|
|
1921
|
+
});
|
|
1922
|
+
// ---------- AI review before publishing ----------
|
|
1923
|
+
const REVIEW_KINDS = ['spelling', 'grammar', 'voice', 'consistency', 'link', 'clarity'];
|
|
1924
|
+
/** The site's pages by address, with their titles: what a link on the page points to. */
|
|
1925
|
+
const pageTitles = async () => {
|
|
1926
|
+
const out = new Map();
|
|
1927
|
+
const config = ctx.config();
|
|
1928
|
+
for (const def of config.collections) {
|
|
1929
|
+
if (!def.url)
|
|
1930
|
+
continue;
|
|
1931
|
+
for (const doc of await content.published(def.name)) {
|
|
1932
|
+
const url = publicUrl(def, doc.locale, doc.publishedData ?? {});
|
|
1933
|
+
if (url && out.size < 200)
|
|
1934
|
+
out.set(url, doc.title);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return out;
|
|
1938
|
+
};
|
|
1939
|
+
/**
|
|
1940
|
+
* The AI reads the draft as it is now (saved or not) and lists what to fix before publishing:
|
|
1941
|
+
* typos, grammar, sentences out of the brand voice, facts that contradict each other, links whose
|
|
1942
|
+
* words promise something else than where they go, unclear sentences. Each with the exact words
|
|
1943
|
+
* and a replacement. Nothing is changed.
|
|
1944
|
+
*/
|
|
1945
|
+
app.post('/api/documents/:id/review', requireUser, async (c) => {
|
|
1946
|
+
const existing = await loadDocument(c.req.param('id'));
|
|
1947
|
+
const input = await body(c, z.object({ data: z.record(z.string(), z.unknown()).optional() }));
|
|
1948
|
+
if (!allowAi(c.get('user').id))
|
|
1949
|
+
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
1950
|
+
const ai = await readyAi();
|
|
1951
|
+
const def = collection(existing.collection);
|
|
1952
|
+
const slots = documentSlots(def.fields, structuredClone(input.data ?? existing.data));
|
|
1953
|
+
if (!slots.length)
|
|
1954
|
+
throw new ApiError(400, 'empty_text', 'Non c’è testo da rivedere');
|
|
1955
|
+
const titles = await pageTitles();
|
|
1956
|
+
const linkTo = (href) => {
|
|
1957
|
+
const path = href.split('#')[0] || href;
|
|
1958
|
+
const title = titles.get(path.replace(/\/+$/, '') || '/');
|
|
1959
|
+
return title ? `${href} (page "${title}")` : href;
|
|
1960
|
+
};
|
|
1961
|
+
let size = 0;
|
|
1962
|
+
const listed = slots.flatMap((s, i) => {
|
|
1963
|
+
if (size > 30_000)
|
|
1964
|
+
return [];
|
|
1965
|
+
size += s.text.length;
|
|
1966
|
+
const links = s.links?.length ? `\n links: ${s.links.map((l, n) => `${n + 1} → ${linkTo(l)}`).join('; ')}` : '';
|
|
1967
|
+
return [`[${i}] ${s.text}${links}`];
|
|
1968
|
+
});
|
|
1969
|
+
const voice = await voiceText('full');
|
|
1970
|
+
const reply = await complete(ai, {
|
|
1971
|
+
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.`,
|
|
1972
|
+
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.
|
|
1973
|
+
|
|
1974
|
+
Find what really needs fixing before publishing, and nothing else:
|
|
1975
|
+
- spelling: typos and misspelled words
|
|
1976
|
+
- grammar: grammar, agreement, punctuation
|
|
1977
|
+
- voice: sentences clearly out of the brand voice above (skip if there is none)
|
|
1978
|
+
- consistency: facts on the page that contradict each other (prices, dates, names, numbers)
|
|
1979
|
+
- link: link words that promise something different from the page they go to
|
|
1980
|
+
- clarity: a sentence a reader would not understand
|
|
1981
|
+
|
|
1982
|
+
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"}]}.
|
|
1983
|
+
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.
|
|
1984
|
+
|
|
1985
|
+
<texts>
|
|
1986
|
+
${listed.join('\n')}
|
|
1987
|
+
</texts>`,
|
|
1988
|
+
}).catch(aiFailed);
|
|
1989
|
+
let raw;
|
|
1990
|
+
try {
|
|
1991
|
+
raw = JSON.parse(reply.replace(/^\s*```(?:json)?|```\s*$/g, '').trim());
|
|
1992
|
+
}
|
|
1993
|
+
catch {
|
|
1994
|
+
throw new ApiError(422, 'ai_failed', 'L’AI non ha risposto come doveva: riprova');
|
|
1995
|
+
}
|
|
1996
|
+
const issue = z.object({
|
|
1997
|
+
slot: z.number().int().min(0),
|
|
1998
|
+
kind: z.enum(REVIEW_KINDS),
|
|
1999
|
+
find: z.string().min(1).max(2000),
|
|
2000
|
+
replace: z.string().max(4000),
|
|
2001
|
+
note: z.string().max(500),
|
|
2002
|
+
});
|
|
2003
|
+
const items = Array.isArray(raw?.issues) ? raw.issues : [];
|
|
2004
|
+
const issues = items.flatMap((it) => {
|
|
2005
|
+
const parsed = issue.safeParse(it);
|
|
2006
|
+
if (!parsed.success)
|
|
2007
|
+
return [];
|
|
2008
|
+
const slot = slots[parsed.data.slot];
|
|
2009
|
+
// Only what is really in the text: a quote the model made up cannot be applied.
|
|
2010
|
+
if (!slot?.text.includes(parsed.data.find))
|
|
2011
|
+
return [];
|
|
2012
|
+
return [{ ...parsed.data, node: slot.node ?? null, context: slot.text.replace(/<[^>]+>/g, '').slice(0, 160) }];
|
|
2013
|
+
});
|
|
2014
|
+
await record(c, 'document.reviewed', existing.title, {
|
|
2015
|
+
collection: existing.collection,
|
|
2016
|
+
id: existing.id,
|
|
2017
|
+
issues: issues.length,
|
|
2018
|
+
});
|
|
2019
|
+
return c.json({ issues: issues.slice(0, 25) });
|
|
2020
|
+
});
|
|
2021
|
+
/**
|
|
2022
|
+
* Applies one suggestion of the review to the draft as the editor holds it, and gives it back:
|
|
2023
|
+
* the editor takes it as one change, which ⌘Z undoes. Nothing is saved here.
|
|
2024
|
+
*/
|
|
2025
|
+
app.post('/api/documents/:id/review/apply', requireUser, async (c) => {
|
|
2026
|
+
const existing = await loadDocument(c.req.param('id'));
|
|
2027
|
+
const input = await body(c, z.object({
|
|
2028
|
+
data: z.record(z.string(), z.unknown()),
|
|
2029
|
+
slot: z.number().int().min(0),
|
|
2030
|
+
find: z.string().min(1).max(2000),
|
|
2031
|
+
replace: z.string().max(4000),
|
|
2032
|
+
}));
|
|
2033
|
+
const data = structuredClone(input.data);
|
|
2034
|
+
const slots = documentSlots(collection(existing.collection).fields, data);
|
|
2035
|
+
// The same text, or the first one that still has those words (the page may have changed since).
|
|
2036
|
+
const slot = slots[input.slot]?.text.includes(input.find)
|
|
2037
|
+
? slots[input.slot]
|
|
2038
|
+
: slots.find((s) => s.text.includes(input.find));
|
|
2039
|
+
if (!slot)
|
|
2040
|
+
throw new ApiError(409, 'changed', 'Quel testo è cambiato: rifai la revisione');
|
|
2041
|
+
slot.set(slot.text.replace(input.find, input.replace));
|
|
2042
|
+
return c.json({ data });
|
|
2043
|
+
});
|
|
1829
2044
|
const TEXT_ACTIONS = {
|
|
1830
2045
|
rewrite: 'Rewrite it so it reads better: same meaning, similar length.',
|
|
1831
2046
|
shorten: 'Make it shorter, keeping what matters most.',
|
|
@@ -1854,8 +2069,12 @@ export function createApp(base) {
|
|
|
1854
2069
|
if (!allowAi(c.get('user').id))
|
|
1855
2070
|
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
1856
2071
|
const ai = await readyAi();
|
|
2072
|
+
// Writing from scratch takes the whole voice; changing a text, only its style.
|
|
2073
|
+
const voice = await voiceText(['seo_title', 'seo_description', 'custom'].includes(input.action) ? 'full' : 'style');
|
|
1857
2074
|
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
|
|
2075
|
+
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.${
|
|
2076
|
+
// Spelling fixes and alternative texts describe: they do not speak with the brand's voice.
|
|
2077
|
+
input.action === 'fix' || input.action === 'alt' || !voice ? '' : `\n\n${voice}`}`;
|
|
1859
2078
|
if (input.action === 'alt') {
|
|
1860
2079
|
const key = /^\/media\/([^/?#@]+)/.exec(input.src ?? '')?.[1];
|
|
1861
2080
|
const file = key ? await files.open(decodeURIComponent(key), { width: 1024, format: 'jpg' }) : null;
|
|
@@ -1948,8 +2167,12 @@ export function createApp(base) {
|
|
|
1948
2167
|
const chatId = input.chatId ?? crypto.randomUUID();
|
|
1949
2168
|
const key = `${user.id}:${chatId}`;
|
|
1950
2169
|
const entry = chats.get(key);
|
|
1951
|
-
const
|
|
1952
|
-
|
|
2170
|
+
const same = !!entry && entry.conv.provider === ai.provider;
|
|
2171
|
+
const conv = same && entry ? entry.conv : { provider: ai.provider, messages: [] };
|
|
2172
|
+
// Fixed when the conversation starts (its instructions may not change): a voice changed
|
|
2173
|
+
// meanwhile applies to the next conversation.
|
|
2174
|
+
const voice = same && entry?.voice !== undefined ? entry.voice : await voiceText();
|
|
2175
|
+
chats.set(key, { conv, at: now, voice });
|
|
1953
2176
|
const before = input.nodes;
|
|
1954
2177
|
const page = { nodes: before };
|
|
1955
2178
|
const auth = {};
|
|
@@ -1964,7 +2187,7 @@ export function createApp(base) {
|
|
|
1964
2187
|
// The instructions stay the same for the whole conversation: the model's earlier reasoning is
|
|
1965
2188
|
// tied to them (a change is refused) and they are read from the prompt cache. What changes
|
|
1966
2189
|
// 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.
|
|
2190
|
+
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
2191
|
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
2192
|
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
2193
|
${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 +2205,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
1982
2205
|
tools,
|
|
1983
2206
|
onEvent: (e) => void send(e),
|
|
1984
2207
|
});
|
|
1985
|
-
chats.set(key, { conv, at: Date.now() });
|
|
2208
|
+
chats.set(key, { conv, at: Date.now(), voice });
|
|
1986
2209
|
if (page.nodes !== before) {
|
|
1987
2210
|
if (limited && !sameDesign(before, page.nodes))
|
|
1988
2211
|
await send({
|
|
@@ -2005,6 +2228,195 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2005
2228
|
}
|
|
2006
2229
|
});
|
|
2007
2230
|
});
|
|
2231
|
+
// A first version of the site from a few lines about the business: new drafts only, theme and
|
|
2232
|
+
// voice proposed. Admins, since the proposal touches the design of the whole site.
|
|
2233
|
+
/** Runs of the site builder, per person, kept two hours: the person may answer the AI and go on. */
|
|
2234
|
+
const briefRuns = new Map();
|
|
2235
|
+
app.post('/api/ai/site', requireUser, requireSession, requireAdmin, async (c) => {
|
|
2236
|
+
const input = await body(c, z
|
|
2237
|
+
.object({
|
|
2238
|
+
brief: z.string().trim().min(20, 'Scrivi qualche riga in più sull’attività').max(6000).optional(),
|
|
2239
|
+
/** A reply in a run already started, with its id. */
|
|
2240
|
+
runId: z.string().max(64).optional(),
|
|
2241
|
+
message: z.string().trim().min(1).max(4000).optional(),
|
|
2242
|
+
theme: z.boolean().default(true),
|
|
2243
|
+
images: z
|
|
2244
|
+
.array(z.object({
|
|
2245
|
+
mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
|
2246
|
+
data: z.base64().max(4_000_000),
|
|
2247
|
+
}))
|
|
2248
|
+
.max(2)
|
|
2249
|
+
.default([]),
|
|
2250
|
+
})
|
|
2251
|
+
.refine((v) => (v.runId ? !!v.message : !!v.brief), { message: 'Scrivi il brief' }));
|
|
2252
|
+
const user = c.get('user');
|
|
2253
|
+
if (!allowAi(user.id))
|
|
2254
|
+
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2255
|
+
const ai = await readyAi();
|
|
2256
|
+
const now = Date.now();
|
|
2257
|
+
for (const [k, v] of briefRuns)
|
|
2258
|
+
if (now - v.at > 2 * 60 * 60 * 1000)
|
|
2259
|
+
briefRuns.delete(k);
|
|
2260
|
+
const runId = input.runId ?? crypto.randomUUID();
|
|
2261
|
+
const key = `${user.id}:${runId}`;
|
|
2262
|
+
let run = input.runId ? briefRuns.get(key) : undefined;
|
|
2263
|
+
if (input.runId && (!run || run.conv.provider !== ai.provider))
|
|
2264
|
+
throw new ApiError(409, 'run_expired', 'Questa conversazione non c’è più: parti da un nuovo brief');
|
|
2265
|
+
if (!run) {
|
|
2266
|
+
const config = ctx.config();
|
|
2267
|
+
const pageCollections = config.collections.filter((d) => !d.singleton && Object.values(d.fields).some((f) => f.kind === 'page'));
|
|
2268
|
+
if (!pageCollections.length)
|
|
2269
|
+
throw new ApiError(400, 'no_pages', 'Il sito non ha una raccolta di pagine costruite sulla tela');
|
|
2270
|
+
const locale = config.locales.default;
|
|
2271
|
+
const existing = [];
|
|
2272
|
+
for (const d of config.collections.filter((x) => !x.singleton)) {
|
|
2273
|
+
const { items } = await content.list(d.name, { locale, limit: 100 });
|
|
2274
|
+
for (const i of items)
|
|
2275
|
+
existing.push(`${d.name}: "${i.title}"${i.slug ? ` (slug ${i.slug})` : ''}`);
|
|
2276
|
+
}
|
|
2277
|
+
// Fixed for the whole run, like the assistant's: the model's reasoning is tied to them.
|
|
2278
|
+
const voice = await voiceText();
|
|
2279
|
+
const askVoice = !voice;
|
|
2280
|
+
run = {
|
|
2281
|
+
conv: { provider: ai.provider, messages: [] },
|
|
2282
|
+
system: briefSystem({
|
|
2283
|
+
site: config.site.name,
|
|
2284
|
+
language: languageName(locale),
|
|
2285
|
+
collections: pageCollections.map((d) => `${d.name} (${d.label ?? d.name})`).join(', '),
|
|
2286
|
+
existing: existing.length ? existing.join('; ') : 'none: the site is empty',
|
|
2287
|
+
voice,
|
|
2288
|
+
askVoice,
|
|
2289
|
+
theme: input.theme,
|
|
2290
|
+
}),
|
|
2291
|
+
result: { created: [], theme: null, voice: null },
|
|
2292
|
+
askVoice,
|
|
2293
|
+
theme: input.theme,
|
|
2294
|
+
at: now,
|
|
2295
|
+
};
|
|
2296
|
+
}
|
|
2297
|
+
const current = run;
|
|
2298
|
+
current.at = now;
|
|
2299
|
+
briefRuns.set(key, current);
|
|
2300
|
+
const auth = {};
|
|
2301
|
+
const authorization = c.req.header('authorization');
|
|
2302
|
+
const cookie = c.req.header('cookie');
|
|
2303
|
+
if (authorization)
|
|
2304
|
+
auth.authorization = authorization;
|
|
2305
|
+
else if (cookie)
|
|
2306
|
+
auth.cookie = cookie;
|
|
2307
|
+
return streamSSE(c, async (stream) => {
|
|
2308
|
+
const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
|
|
2309
|
+
const stop = new AbortController();
|
|
2310
|
+
stream.onAbort(() => stop.abort());
|
|
2311
|
+
const { tools, result } = briefTools(apiCaller(app.fetch, auth), {
|
|
2312
|
+
askVoice: current.askVoice,
|
|
2313
|
+
result: current.result,
|
|
2314
|
+
onCreated: (doc) => void send({ type: 'created', doc }),
|
|
2315
|
+
});
|
|
2316
|
+
const before = result.created.length;
|
|
2317
|
+
try {
|
|
2318
|
+
const reply = await runTurn(ai, current.conv, {
|
|
2319
|
+
system: current.system,
|
|
2320
|
+
text: input.runId ? (input.message ?? '') : `<brief>\n${input.brief}\n</brief>`,
|
|
2321
|
+
images: input.images,
|
|
2322
|
+
tools: current.theme ? tools : tools.filter((t) => t.name !== 'propose_theme'),
|
|
2323
|
+
onEvent: (e) => void send(e),
|
|
2324
|
+
maxSteps: 80,
|
|
2325
|
+
signal: stop.signal,
|
|
2326
|
+
});
|
|
2327
|
+
if (result.created.length > before)
|
|
2328
|
+
await record(c, 'site.generated', '', { pages: result.created.length - before });
|
|
2329
|
+
await send({ type: 'result', ...result });
|
|
2330
|
+
await send({ type: 'text', text: reply });
|
|
2331
|
+
await send({ type: 'done', runId });
|
|
2332
|
+
}
|
|
2333
|
+
catch (error) {
|
|
2334
|
+
if (!(error instanceof AiError))
|
|
2335
|
+
console.error(error);
|
|
2336
|
+
// What was made before the failure stays, as drafts: the panel lists it. The run ends.
|
|
2337
|
+
briefRuns.delete(key);
|
|
2338
|
+
await send({ type: 'result', ...result });
|
|
2339
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2340
|
+
await send({ type: 'done' });
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
});
|
|
2344
|
+
/** Requests from the dashboard, per person, kept two hours so the person can answer and go on. */
|
|
2345
|
+
const studioRuns = new Map();
|
|
2346
|
+
app.post('/api/ai/studio', requireUser, async (c) => {
|
|
2347
|
+
const input = await body(c, z.object({
|
|
2348
|
+
runId: z.string().max(64).optional(),
|
|
2349
|
+
message: z.string().trim().min(1).max(6000),
|
|
2350
|
+
images: z
|
|
2351
|
+
.array(z.object({
|
|
2352
|
+
mime: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
|
2353
|
+
data: z.base64().max(4_000_000),
|
|
2354
|
+
}))
|
|
2355
|
+
.max(4)
|
|
2356
|
+
.default([]),
|
|
2357
|
+
}));
|
|
2358
|
+
const user = c.get('user');
|
|
2359
|
+
if (!allowAi(user.id))
|
|
2360
|
+
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
2361
|
+
const ai = await readyAi();
|
|
2362
|
+
const now = Date.now();
|
|
2363
|
+
for (const [k, v] of studioRuns)
|
|
2364
|
+
if (now - v.at > 2 * 60 * 60 * 1000)
|
|
2365
|
+
studioRuns.delete(k);
|
|
2366
|
+
const runId = input.runId ?? crypto.randomUUID();
|
|
2367
|
+
const key = `${user.id}:${runId}`;
|
|
2368
|
+
const found = input.runId ? studioRuns.get(key) : undefined;
|
|
2369
|
+
const run = found && found.conv.provider === ai.provider
|
|
2370
|
+
? found
|
|
2371
|
+
: {
|
|
2372
|
+
conv: { provider: ai.provider, messages: [] },
|
|
2373
|
+
// Fixed for the whole conversation: the model's reasoning is tied to it.
|
|
2374
|
+
system: studioSystem({
|
|
2375
|
+
site: ctx.config().site.name,
|
|
2376
|
+
user: user.name,
|
|
2377
|
+
voice: await voiceText(),
|
|
2378
|
+
limited: await contentOnly(user),
|
|
2379
|
+
}),
|
|
2380
|
+
at: now,
|
|
2381
|
+
};
|
|
2382
|
+
run.at = now;
|
|
2383
|
+
studioRuns.set(key, run);
|
|
2384
|
+
const credentials = credentialsOf(c);
|
|
2385
|
+
return streamSSE(c, async (stream) => {
|
|
2386
|
+
const send = (data) => stream.writeSSE({ data: JSON.stringify(data) });
|
|
2387
|
+
const stop = new AbortController();
|
|
2388
|
+
stream.onAbort(() => stop.abort());
|
|
2389
|
+
const seen = new Set();
|
|
2390
|
+
const tools = studioTools(apiCaller(app.fetch, credentials), (doc) => {
|
|
2391
|
+
if (seen.has(doc.id))
|
|
2392
|
+
return;
|
|
2393
|
+
seen.add(doc.id);
|
|
2394
|
+
void send({ type: 'touched', doc });
|
|
2395
|
+
});
|
|
2396
|
+
try {
|
|
2397
|
+
const reply = await runTurn(ai, run.conv, {
|
|
2398
|
+
system: run.system,
|
|
2399
|
+
text: input.message,
|
|
2400
|
+
images: input.images,
|
|
2401
|
+
tools,
|
|
2402
|
+
onEvent: (e) => void send(e),
|
|
2403
|
+
maxSteps: 40,
|
|
2404
|
+
signal: stop.signal,
|
|
2405
|
+
});
|
|
2406
|
+
if (seen.size)
|
|
2407
|
+
await record(c, 'site.ai_request', '', { documents: seen.size });
|
|
2408
|
+
await send({ type: 'text', text: reply });
|
|
2409
|
+
await send({ type: 'done', runId });
|
|
2410
|
+
}
|
|
2411
|
+
catch (error) {
|
|
2412
|
+
if (!(error instanceof AiError))
|
|
2413
|
+
console.error(error);
|
|
2414
|
+
studioRuns.delete(key);
|
|
2415
|
+
await send({ type: 'error', message: error instanceof Error ? error.message : 'Errore' });
|
|
2416
|
+
await send({ type: 'done' });
|
|
2417
|
+
}
|
|
2418
|
+
});
|
|
2419
|
+
});
|
|
2008
2420
|
// ---------- forms ----------
|
|
2009
2421
|
/**
|
|
2010
2422
|
* Every form on the published site, by id, as the server checks it: canvas pages with their
|
|
@@ -2081,6 +2493,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2081
2493
|
const fingerprint = fingerprintOf(form, data);
|
|
2082
2494
|
const spam = spamReason(data, elapsed) ??
|
|
2083
2495
|
((await submissions.seen(fingerprint, new Date(Date.now() - 86_400_000))) ? 'Messaggio ripetuto' : null);
|
|
2496
|
+
// A signup form: no message, a subscriber waiting to confirm. Spam gets the same answer.
|
|
2497
|
+
if (def?.newsletter) {
|
|
2498
|
+
if (!spam)
|
|
2499
|
+
await signUp(c, def, data, page);
|
|
2500
|
+
return wantsJson ? c.json({ ok: true }) : c.redirect(`${back}#inviato`, 303);
|
|
2501
|
+
}
|
|
2084
2502
|
const formName = def?.name?.trim() || def?.subject?.trim() || '';
|
|
2085
2503
|
const saved = await submissions.add({ form, formName, page, data, spam, fingerprint });
|
|
2086
2504
|
if (files.length)
|
|
@@ -2563,6 +2981,17 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2563
2981
|
});
|
|
2564
2982
|
return c.json(await toJson(existing));
|
|
2565
2983
|
});
|
|
2984
|
+
/** Why a document's draft cannot go on the site now, in words; null when it can. */
|
|
2985
|
+
const publishProblem = (doc) => {
|
|
2986
|
+
const def = ctx.config().collections.find((x) => x.name === doc.collection);
|
|
2987
|
+
if (!def)
|
|
2988
|
+
return 'La raccolta non esiste più';
|
|
2989
|
+
const check = validate(def, prepare(def, doc.data));
|
|
2990
|
+
if (check.ok)
|
|
2991
|
+
return null;
|
|
2992
|
+
const fields = [...new Set(check.issues.map((i) => def.fields[i.path.split('.')[0] ?? '']?.label ?? i.path))];
|
|
2993
|
+
return `Alcuni campi vanno completati${fields.length ? `: ${fields.join(', ')}` : ''}`;
|
|
2994
|
+
};
|
|
2566
2995
|
/**
|
|
2567
2996
|
* Runs the actions whose time has come, as the person who set them. A server that was off at the
|
|
2568
2997
|
* time catches up when it starts. A publication whose fields are no longer complete fails with a
|
|
@@ -2593,14 +3022,9 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2593
3022
|
}
|
|
2594
3023
|
try {
|
|
2595
3024
|
if (item.action === 'publish') {
|
|
2596
|
-
const
|
|
2597
|
-
|
|
2598
|
-
|
|
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
|
-
}
|
|
3025
|
+
const problem = publishProblem(existing);
|
|
3026
|
+
if (problem)
|
|
3027
|
+
throw new Error(problem);
|
|
2604
3028
|
const { doc, url } = await publishDocument(existing, item.createdBy ?? undefined);
|
|
2605
3029
|
await schedules.finish(item.id, 'done');
|
|
2606
3030
|
await note('document.published', doc.title, { collection: doc.collection, id: doc.id, url });
|
|
@@ -2623,6 +3047,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2623
3047
|
});
|
|
2624
3048
|
}
|
|
2625
3049
|
}
|
|
3050
|
+
for (const release of await releases.due(now)) {
|
|
3051
|
+
const by = release.scheduledBy ? { id: release.scheduledBy, name: release.scheduledByName } : null;
|
|
3052
|
+
if (await releaseOut(release, by))
|
|
3053
|
+
ran++;
|
|
3054
|
+
}
|
|
2626
3055
|
}
|
|
2627
3056
|
catch (error) {
|
|
2628
3057
|
console.error('Materialize: scheduled publishing failed', error);
|
|
@@ -2648,6 +3077,677 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2648
3077
|
}),
|
|
2649
3078
|
});
|
|
2650
3079
|
});
|
|
3080
|
+
/** What stops a release from going out: every document must be ready, or none goes. */
|
|
3081
|
+
const releaseProblems = async (release) => {
|
|
3082
|
+
const out = [];
|
|
3083
|
+
for (const item of release.items) {
|
|
3084
|
+
const doc = await content.get(item.documentId);
|
|
3085
|
+
if (!doc)
|
|
3086
|
+
out.push({ documentId: item.documentId, title: '', message: 'La pagina non esiste più' });
|
|
3087
|
+
else if (item.action === 'publish') {
|
|
3088
|
+
const message = publishProblem(doc);
|
|
3089
|
+
if (message)
|
|
3090
|
+
out.push({ documentId: doc.id, title: doc.title, message });
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
if (!release.items.length)
|
|
3094
|
+
out.push({ documentId: '', title: '', message: 'Il rilascio è vuoto' });
|
|
3095
|
+
return out;
|
|
3096
|
+
};
|
|
3097
|
+
/**
|
|
3098
|
+
* Puts a release on the site: checked first, all or nothing. Returns whether it went out; when
|
|
3099
|
+
* it did not, the release is marked failed with the reason, and the panel shows it.
|
|
3100
|
+
*/
|
|
3101
|
+
const releaseOut = async (release, by) => {
|
|
3102
|
+
const note = (action, target, detail) => activityLog
|
|
3103
|
+
.record({ userId: by?.id ?? null, userName: by?.name ?? '', action, target, detail, ip: null })
|
|
3104
|
+
.catch((error) => console.error('Materialize: activity not recorded', error));
|
|
3105
|
+
const problems = await releaseProblems(release);
|
|
3106
|
+
if (problems.length) {
|
|
3107
|
+
const message = problems.map((p) => (p.title ? `${p.title}: ${p.message}` : p.message)).join('; ');
|
|
3108
|
+
await releases.finish(release.id, 'failed', message);
|
|
3109
|
+
await note('release.failed', release.name, { id: release.id, message });
|
|
3110
|
+
return false;
|
|
3111
|
+
}
|
|
3112
|
+
const done = [];
|
|
3113
|
+
try {
|
|
3114
|
+
for (const item of release.items) {
|
|
3115
|
+
const doc = await content.get(item.documentId);
|
|
3116
|
+
if (!doc)
|
|
3117
|
+
continue;
|
|
3118
|
+
if (item.action === 'publish') {
|
|
3119
|
+
await publishDocument(doc, by?.id);
|
|
3120
|
+
await schedules.cancel(doc.id, 'publish');
|
|
3121
|
+
}
|
|
3122
|
+
else if (doc.publishedAt) {
|
|
3123
|
+
await unpublishDocument(doc, by?.id);
|
|
3124
|
+
await schedules.cancel(doc.id, 'unpublish');
|
|
3125
|
+
}
|
|
3126
|
+
done.push(doc.title);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
catch (error) {
|
|
3130
|
+
const why = error instanceof Error && error.message ? error.message : 'Errore imprevisto';
|
|
3131
|
+
const message = `${why}${done.length ? ` (già uscite: ${done.join(', ')})` : ''}`;
|
|
3132
|
+
await releases.finish(release.id, 'failed', message);
|
|
3133
|
+
await note('release.failed', release.name, { id: release.id, message });
|
|
3134
|
+
return false;
|
|
3135
|
+
}
|
|
3136
|
+
await releases.finish(release.id, 'published');
|
|
3137
|
+
await note('release.published', release.name, { id: release.id, items: release.items.length });
|
|
3138
|
+
return true;
|
|
3139
|
+
};
|
|
3140
|
+
/** A release as the panel shows it: each document with its title, state and what stops it. */
|
|
3141
|
+
const releaseJson = async (release) => {
|
|
3142
|
+
const problems = release.status === 'published' ? [] : await releaseProblems(release);
|
|
3143
|
+
const items = [];
|
|
3144
|
+
for (const item of release.items) {
|
|
3145
|
+
const doc = await content.get(item.documentId);
|
|
3146
|
+
if (!doc)
|
|
3147
|
+
continue;
|
|
3148
|
+
items.push({
|
|
3149
|
+
...item,
|
|
3150
|
+
title: doc.title,
|
|
3151
|
+
collection: doc.collection,
|
|
3152
|
+
locale: doc.locale,
|
|
3153
|
+
status: doc.status,
|
|
3154
|
+
problem: problems.find((p) => p.documentId === doc.id)?.message ?? null,
|
|
3155
|
+
});
|
|
3156
|
+
}
|
|
3157
|
+
return { ...release, items, ready: problems.length === 0 };
|
|
3158
|
+
};
|
|
3159
|
+
const loadRelease = async (id) => {
|
|
3160
|
+
const release = await releases.get(id);
|
|
3161
|
+
if (!release)
|
|
3162
|
+
throw new ApiError(404, 'not_found', 'Questo rilascio non esiste');
|
|
3163
|
+
return release;
|
|
3164
|
+
};
|
|
3165
|
+
const openRelease = async (id) => {
|
|
3166
|
+
const release = await loadRelease(id);
|
|
3167
|
+
if (release.status === 'published' || release.status === 'cancelled')
|
|
3168
|
+
throw new ApiError(409, 'release_closed', 'Questo rilascio è già uscito o è stato annullato');
|
|
3169
|
+
return release;
|
|
3170
|
+
};
|
|
3171
|
+
app.get('/api/releases', requireUser, async (c) => {
|
|
3172
|
+
const { active, done } = await releases.list();
|
|
3173
|
+
return c.json({
|
|
3174
|
+
releases: await Promise.all(active.map(releaseJson)),
|
|
3175
|
+
published: await Promise.all(done.map(releaseJson)),
|
|
3176
|
+
});
|
|
3177
|
+
});
|
|
3178
|
+
app.post('/api/releases', requireUser, async (c) => {
|
|
3179
|
+
const input = await body(c, z.object({ name: z.string().trim().min(1).max(120) }));
|
|
3180
|
+
const release = await releases.create(input.name, c.get('user'));
|
|
3181
|
+
await record(c, 'release.created', release.name, { id: release.id });
|
|
3182
|
+
return c.json(await releaseJson(release), 201);
|
|
3183
|
+
});
|
|
3184
|
+
app.get('/api/releases/:id', requireUser, async (c) => c.json(await releaseJson(await loadRelease(c.req.param('id')))));
|
|
3185
|
+
app.patch('/api/releases/:id', requireUser, async (c) => {
|
|
3186
|
+
const release = await openRelease(c.req.param('id'));
|
|
3187
|
+
const input = await body(c, z.object({ name: z.string().trim().min(1).max(120) }));
|
|
3188
|
+
await releases.rename(release.id, input.name);
|
|
3189
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3190
|
+
});
|
|
3191
|
+
/** Cancels a release: its documents stay as they are. */
|
|
3192
|
+
app.delete('/api/releases/:id', requireUser, async (c) => {
|
|
3193
|
+
const release = await openRelease(c.req.param('id'));
|
|
3194
|
+
await releases.finish(release.id, 'cancelled');
|
|
3195
|
+
await record(c, 'release.cancelled', release.name, { id: release.id });
|
|
3196
|
+
return c.body(null, 204);
|
|
3197
|
+
});
|
|
3198
|
+
/** Adds a document (or changes what happens to it): it leaves any other release. */
|
|
3199
|
+
app.put('/api/releases/:id/items/:documentId', requireUser, async (c) => {
|
|
3200
|
+
const release = await openRelease(c.req.param('id'));
|
|
3201
|
+
const doc = await loadDocument(c.req.param('documentId'));
|
|
3202
|
+
const input = await body(c, z.object({ action: z.enum(['publish', 'unpublish']).default('publish') }));
|
|
3203
|
+
if (input.action === 'unpublish' && !doc.publishedAt)
|
|
3204
|
+
throw new ApiError(409, 'not_published', 'La pagina non è sul sito: non c’è niente da togliere');
|
|
3205
|
+
await releases.put(release.id, doc.id, input.action);
|
|
3206
|
+
await record(c, 'release.item_added', doc.title, { id: release.id, release: release.name, action: input.action });
|
|
3207
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3208
|
+
});
|
|
3209
|
+
app.delete('/api/releases/:id/items/:documentId', requireUser, async (c) => {
|
|
3210
|
+
const release = await openRelease(c.req.param('id'));
|
|
3211
|
+
await releases.remove(release.id, c.req.param('documentId'));
|
|
3212
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3213
|
+
});
|
|
3214
|
+
/** Everything in the release goes on the site now, or nothing does (the reasons come back). */
|
|
3215
|
+
app.post('/api/releases/:id/publish', requireUser, async (c) => {
|
|
3216
|
+
const release = await openRelease(c.req.param('id'));
|
|
3217
|
+
const problems = await releaseProblems(release);
|
|
3218
|
+
if (problems.length)
|
|
3219
|
+
return c.json({
|
|
3220
|
+
error: {
|
|
3221
|
+
code: 'release_not_ready',
|
|
3222
|
+
message: 'Alcune pagine non sono pronte: non è uscito niente',
|
|
3223
|
+
problems,
|
|
3224
|
+
},
|
|
3225
|
+
}, 422);
|
|
3226
|
+
const user = c.get('user');
|
|
3227
|
+
const ok = await releaseOut(release, user);
|
|
3228
|
+
const after = await loadRelease(release.id);
|
|
3229
|
+
if (!ok)
|
|
3230
|
+
throw new ApiError(422, 'release_failed', after.error ?? 'Il rilascio non è uscito');
|
|
3231
|
+
return c.json(await releaseJson(after));
|
|
3232
|
+
});
|
|
3233
|
+
/** When the release goes out by itself; at null: only by hand. Checked now, and again at that moment. */
|
|
3234
|
+
app.put('/api/releases/:id/schedule', requireUser, async (c) => {
|
|
3235
|
+
const release = await openRelease(c.req.param('id'));
|
|
3236
|
+
const input = await body(c, z.object({ at: z.iso.datetime({ offset: true }).nullable() }));
|
|
3237
|
+
const user = c.get('user');
|
|
3238
|
+
if (input.at === null) {
|
|
3239
|
+
await releases.schedule(release.id, null, null);
|
|
3240
|
+
await record(c, 'release.schedule_cancelled', release.name, { id: release.id });
|
|
3241
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3242
|
+
}
|
|
3243
|
+
const at = new Date(input.at);
|
|
3244
|
+
if (at.getTime() <= Date.now() + 30_000)
|
|
3245
|
+
throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
|
|
3246
|
+
if (!release.items.length)
|
|
3247
|
+
throw new ApiError(400, 'empty_release', 'Aggiungi prima qualcosa al rilascio');
|
|
3248
|
+
await releases.schedule(release.id, at, user);
|
|
3249
|
+
await record(c, 'release.scheduled', release.name, { id: release.id, at: at.toISOString() });
|
|
3250
|
+
return c.json(await releaseJson(await loadRelease(release.id)));
|
|
3251
|
+
});
|
|
3252
|
+
// ---------- newsletter ----------
|
|
3253
|
+
const escapeHtml = (v) => v.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
3254
|
+
const newsletterSettings = async () => (await settings.get('newsletter')) ?? {};
|
|
3255
|
+
/** The secret that signs tracked links, made the first time it is needed. */
|
|
3256
|
+
const newsletterSecret = async () => {
|
|
3257
|
+
const found = await settings.get('newsletter:secret');
|
|
3258
|
+
if (found)
|
|
3259
|
+
return found;
|
|
3260
|
+
const secret = randomBytes(24).toString('base64url');
|
|
3261
|
+
await settings.set('newsletter:secret', secret);
|
|
3262
|
+
return secret;
|
|
3263
|
+
};
|
|
3264
|
+
/** Where links in newsletters point: the site's address, or the one last used in the panel. */
|
|
3265
|
+
const newsletterOrigin = async (c) => {
|
|
3266
|
+
if (c) {
|
|
3267
|
+
const origin = publicOrigin(c);
|
|
3268
|
+
const current = await newsletterSettings();
|
|
3269
|
+
if (current.origin !== origin)
|
|
3270
|
+
await settings.set('newsletter', { ...current, origin });
|
|
3271
|
+
return origin;
|
|
3272
|
+
}
|
|
3273
|
+
const site = ctx.config().site.url;
|
|
3274
|
+
try {
|
|
3275
|
+
if (site)
|
|
3276
|
+
return new URL(site).origin;
|
|
3277
|
+
}
|
|
3278
|
+
catch { }
|
|
3279
|
+
return (await newsletterSettings()).origin ?? 'http://localhost:4321';
|
|
3280
|
+
};
|
|
3281
|
+
const accentColor = async () => normalizeTheme(await settings.get('theme')).colors.light.accent;
|
|
3282
|
+
const allowSignup = createLimiter(5, 60_000);
|
|
3283
|
+
/** A signup from a form: the email field, a name if there is one, and the confirmation email. */
|
|
3284
|
+
const signUp = async (c, def, data, page) => {
|
|
3285
|
+
const field = def.fields.find((f) => f.kind === 'email') ?? def.fields.find((f) => /mail/i.test(f.name));
|
|
3286
|
+
const email = (field ? data[field.name] : '')?.trim().toLowerCase() ?? '';
|
|
3287
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
|
3288
|
+
return;
|
|
3289
|
+
if (!allowSignup(`${clientIp(c)}:newsletter`))
|
|
3290
|
+
return;
|
|
3291
|
+
const nameField = def.fields.find((f) => f.kind !== 'email' && /^(nome|name|first.?name)$/i.test(f.name));
|
|
3292
|
+
const { subscriber, confirm } = await newsletter.subscribe({
|
|
3293
|
+
email,
|
|
3294
|
+
name: nameField ? data[nameField.name] : '',
|
|
3295
|
+
locale: langOf(c.req.header('accept-language')),
|
|
3296
|
+
source: page || def.name || def.id,
|
|
3297
|
+
});
|
|
3298
|
+
if (!confirm)
|
|
3299
|
+
return;
|
|
3300
|
+
const mail = await settings.get('mail');
|
|
3301
|
+
if (!isConfigured(mail)) {
|
|
3302
|
+
console.warn('Materialize: a newsletter signup waits for confirmation, but email is not set up');
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
const url = `${await newsletterOrigin()}/api/newsletter/confirm/${subscriber.token}`;
|
|
3306
|
+
const message = confirmEmail({
|
|
3307
|
+
site: ctx.config().site.name,
|
|
3308
|
+
url,
|
|
3309
|
+
accent: await accentColor(),
|
|
3310
|
+
locale: subscriber.locale,
|
|
3311
|
+
});
|
|
3312
|
+
await sendMail(mail, { to: email, ...message }).catch((error) => console.error('Materialize: confirmation email not sent', error));
|
|
3313
|
+
};
|
|
3314
|
+
/** A small page for people coming from an email link: no panel, the site's name and a sentence. */
|
|
3315
|
+
const notice = (c, lang, title, text, form) => c.html(`<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${escapeHtml(title)}</title>
|
|
3316
|
+
<style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b}main{max-width:420px;margin:24px;padding:32px;background:#fff;border-radius:14px;box-shadow:0 1px 3px rgba(0,0,0,.08)}h1{margin:0 0 10px;font-size:22px}p{margin:0 0 16px;line-height:1.6;color:#52525b}button{padding:11px 18px;border:0;border-radius:8px;background:#18181b;color:#fff;font:inherit;font-weight:600;cursor:pointer}a{color:inherit}</style></head>
|
|
3317
|
+
<body><main><p style="font-weight:700;color:#18181b">${escapeHtml(ctx.config().site.name)}</p><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${form ?? ''}<p><a href="/">${lang === 'it' ? 'Vai al sito' : 'Go to the site'}</a></p></main></body></html>`);
|
|
3318
|
+
app.get('/api/newsletter/confirm/:token', async (c) => {
|
|
3319
|
+
const lang = langOf(c.req.header('accept-language'));
|
|
3320
|
+
const found = await newsletter.confirm(c.req.param('token'));
|
|
3321
|
+
if (!found)
|
|
3322
|
+
return notice(c, lang, lang === 'it' ? 'Link non valido' : 'Link not valid', lang === 'it'
|
|
3323
|
+
? 'Questo link non funziona più. Iscriviti di nuovo dal sito, se vuoi ricevere la newsletter.'
|
|
3324
|
+
: 'This link no longer works. Sign up again on the site if you want the newsletter.');
|
|
3325
|
+
return notice(c, langOf(found.locale), langOf(found.locale) === 'it' ? 'Iscrizione confermata' : 'Subscription confirmed', langOf(found.locale) === 'it'
|
|
3326
|
+
? `Da ora riceverai la newsletter di ${ctx.config().site.name} a ${found.email}. Puoi disiscriverti quando vuoi, dal link in fondo a ogni email.`
|
|
3327
|
+
: `From now on you will get the ${ctx.config().site.name} newsletter at ${found.email}. You can unsubscribe whenever you like, from the link at the bottom of every email.`);
|
|
3328
|
+
});
|
|
3329
|
+
// A page with a button, so that link scanners opening the address do not unsubscribe anyone;
|
|
3330
|
+
// email apps' one-click unsubscribe posts to the same address (RFC 8058).
|
|
3331
|
+
app.get('/api/newsletter/unsubscribe/:token', async (c) => {
|
|
3332
|
+
const found = await newsletter.byToken(c.req.param('token'));
|
|
3333
|
+
const lang = langOf(found?.locale ?? c.req.header('accept-language'));
|
|
3334
|
+
if (!found)
|
|
3335
|
+
return notice(c, lang, lang === 'it' ? 'Link non valido' : 'Link not valid', lang === 'it' ? 'Questo link non funziona.' : 'This link does not work.');
|
|
3336
|
+
if (found.status === 'unsubscribed')
|
|
3337
|
+
return notice(c, lang, lang === 'it' ? 'Sei già disiscritto' : 'You are already unsubscribed', lang === 'it'
|
|
3338
|
+
? `${found.email} non riceve più la newsletter.`
|
|
3339
|
+
: `${found.email} no longer gets the newsletter.`);
|
|
3340
|
+
const send = c.req.query('s') ?? '';
|
|
3341
|
+
return notice(c, lang, lang === 'it' ? 'Disiscriviti dalla newsletter' : 'Unsubscribe from the newsletter', lang === 'it'
|
|
3342
|
+
? `${found.email} non riceverà più la newsletter di ${ctx.config().site.name}.`
|
|
3343
|
+
: `${found.email} will no longer get the ${ctx.config().site.name} newsletter.`, `<form method="post"><input type="hidden" name="s" value="${escapeHtml(send)}"><p><button type="submit">${lang === 'it' ? 'Disiscrivimi' : 'Unsubscribe me'}</button></p></form>`);
|
|
3344
|
+
});
|
|
3345
|
+
app.post('/api/newsletter/unsubscribe/:token', async (c) => {
|
|
3346
|
+
const found = await newsletter.byToken(c.req.param('token'));
|
|
3347
|
+
const lang = langOf(found?.locale ?? c.req.header('accept-language'));
|
|
3348
|
+
if (!found)
|
|
3349
|
+
return notice(c, lang, lang === 'it' ? 'Link non valido' : 'Link not valid', '');
|
|
3350
|
+
const form = (await c.req.parseBody().catch(() => ({})));
|
|
3351
|
+
const send = typeof form.s === 'string' ? form.s : (c.req.query('s') ?? '');
|
|
3352
|
+
if (found.status !== 'unsubscribed') {
|
|
3353
|
+
await newsletter.unsubscribe(found.id);
|
|
3354
|
+
const sent = send ? await newsletter.send(send) : null;
|
|
3355
|
+
if (sent && sent.subscriberId === found.id)
|
|
3356
|
+
await newsletter.track(sent.id, 'unsubscribe');
|
|
3357
|
+
}
|
|
3358
|
+
return notice(c, lang, lang === 'it' ? 'Fatto: sei disiscritto' : 'Done: you are unsubscribed', lang === 'it'
|
|
3359
|
+
? 'Non riceverai più la newsletter. Se cambi idea, puoi iscriverti di nuovo dal sito.'
|
|
3360
|
+
: 'You will not get the newsletter any more. If you change your mind, you can sign up again on the site.');
|
|
3361
|
+
});
|
|
3362
|
+
/** Statistics: the picture that tells the email was opened. */
|
|
3363
|
+
app.get('/api/newsletter/o/:send', async (c) => {
|
|
3364
|
+
const id = c.req.param('send').replace(/\.gif$/, '');
|
|
3365
|
+
if ((await newsletterSettings()).tracking !== false && (await newsletter.send(id)))
|
|
3366
|
+
await newsletter.track(id, 'open');
|
|
3367
|
+
return c.body(new Uint8Array(PIXEL), 200, { 'content-type': 'image/gif', 'cache-control': 'no-store' });
|
|
3368
|
+
});
|
|
3369
|
+
/** Statistics: a link followed, then on to where it points (only addresses this site signed). */
|
|
3370
|
+
app.get('/api/newsletter/c/:send', async (c) => {
|
|
3371
|
+
const id = c.req.param('send');
|
|
3372
|
+
const url = c.req.query('u') ?? '';
|
|
3373
|
+
if (!/^https?:\/\//i.test(url) || !validLink(id, url, c.req.query('s') ?? '', await newsletterSecret()))
|
|
3374
|
+
return c.text('Link not valid', 400);
|
|
3375
|
+
if (await newsletter.send(id))
|
|
3376
|
+
await newsletter.track(id, 'click');
|
|
3377
|
+
return c.redirect(url, 302);
|
|
3378
|
+
});
|
|
3379
|
+
// ----- panel: subscribers -----
|
|
3380
|
+
app.get('/api/subscribers', requireUser, async (c) => {
|
|
3381
|
+
const status = c.req.query('status');
|
|
3382
|
+
return c.json(await newsletter.subscribers({
|
|
3383
|
+
status: status === 'pending' || status === 'active' || status === 'unsubscribed' ? status : undefined,
|
|
3384
|
+
search: c.req.query('search') || undefined,
|
|
3385
|
+
limit: Math.min(200, Number(c.req.query('limit') ?? 50)),
|
|
3386
|
+
offset: Number(c.req.query('offset') ?? 0),
|
|
3387
|
+
}));
|
|
3388
|
+
});
|
|
3389
|
+
/** Someone added by hand gets the confirmation email too: consent is theirs to give. */
|
|
3390
|
+
app.post('/api/subscribers', requireUser, requireAdmin, async (c) => {
|
|
3391
|
+
const input = await body(c, z.object({ email: z.email(), name: z.string().trim().max(120).optional() }));
|
|
3392
|
+
const { subscriber, confirm } = await newsletter.subscribe({ ...input, source: 'panel', locale: null });
|
|
3393
|
+
let mailed = false;
|
|
3394
|
+
if (confirm) {
|
|
3395
|
+
const mail = await settings.get('mail');
|
|
3396
|
+
if (isConfigured(mail)) {
|
|
3397
|
+
const url = `${await newsletterOrigin(c)}/api/newsletter/confirm/${subscriber.token}`;
|
|
3398
|
+
await sendMail(mail, {
|
|
3399
|
+
to: subscriber.email,
|
|
3400
|
+
...confirmEmail({
|
|
3401
|
+
site: ctx.config().site.name,
|
|
3402
|
+
url,
|
|
3403
|
+
accent: await accentColor(),
|
|
3404
|
+
locale: ctx.config().locales.default,
|
|
3405
|
+
}),
|
|
3406
|
+
});
|
|
3407
|
+
mailed = true;
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
await record(c, 'newsletter.subscriber_added', subscriber.email);
|
|
3411
|
+
return c.json({ subscriber, mailed }, 201);
|
|
3412
|
+
});
|
|
3413
|
+
app.delete('/api/subscribers/:id', requireUser, requireAdmin, async (c) => {
|
|
3414
|
+
const found = await newsletter.subscriber(c.req.param('id'));
|
|
3415
|
+
if (!found)
|
|
3416
|
+
throw new ApiError(404, 'not_found', 'Questo iscritto non esiste');
|
|
3417
|
+
await newsletter.removeSubscriber(found.id);
|
|
3418
|
+
await record(c, 'newsletter.subscriber_removed', found.email);
|
|
3419
|
+
return c.body(null, 204);
|
|
3420
|
+
});
|
|
3421
|
+
app.post('/api/subscribers/import', requireUser, requireAdmin, async (c) => {
|
|
3422
|
+
const input = await body(c, z.object({ csv: z.string().max(5_000_000) }));
|
|
3423
|
+
const rows = parseSubscribersCsv(input.csv);
|
|
3424
|
+
if (!rows.length)
|
|
3425
|
+
throw new ApiError(422, 'no_addresses', 'Nel file non ho trovato indirizzi email');
|
|
3426
|
+
const result = await newsletter.importMany(rows);
|
|
3427
|
+
await record(c, 'newsletter.imported', '', result);
|
|
3428
|
+
return c.json({ found: rows.length, ...result });
|
|
3429
|
+
});
|
|
3430
|
+
app.get('/api/subscribers/export.csv', requireUser, requireAdmin, async (c) => {
|
|
3431
|
+
const cell = (v) => {
|
|
3432
|
+
const safe = /^[=+\-@\t\r]/.test(v) ? `'${v}` : v;
|
|
3433
|
+
return /[",;\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
3434
|
+
};
|
|
3435
|
+
const rows = (await newsletter.allSubscribers()).map((s) => [
|
|
3436
|
+
s.email,
|
|
3437
|
+
s.name,
|
|
3438
|
+
s.status,
|
|
3439
|
+
s.source,
|
|
3440
|
+
s.createdAt.toISOString(),
|
|
3441
|
+
s.confirmedAt?.toISOString() ?? '',
|
|
3442
|
+
s.unsubscribedAt?.toISOString() ?? '',
|
|
3443
|
+
]
|
|
3444
|
+
.map(cell)
|
|
3445
|
+
.join(','));
|
|
3446
|
+
const csv = `email,name,status,source,created,confirmed,unsubscribed\n${rows.join('\n')}\n`;
|
|
3447
|
+
await record(c, 'newsletter.exported', '', { subscribers: rows.length });
|
|
3448
|
+
return c.body(csv, 200, {
|
|
3449
|
+
'content-type': 'text/csv; charset=utf-8',
|
|
3450
|
+
'content-disposition': 'attachment; filename="iscritti.csv"',
|
|
3451
|
+
});
|
|
3452
|
+
});
|
|
3453
|
+
app.get('/api/newsletter/settings', requireUser, async (c) => {
|
|
3454
|
+
const s = await newsletterSettings();
|
|
3455
|
+
return c.json({ footer: s.footer ?? '', tracking: s.tracking !== false });
|
|
3456
|
+
});
|
|
3457
|
+
app.put('/api/newsletter/settings', requireUser, requireAdmin, async (c) => {
|
|
3458
|
+
const input = await body(c, z.object({ footer: z.string().max(1000), tracking: z.boolean() }));
|
|
3459
|
+
await settings.set('newsletter', { ...(await newsletterSettings()), ...input });
|
|
3460
|
+
await record(c, 'settings.newsletter');
|
|
3461
|
+
return c.json(input);
|
|
3462
|
+
});
|
|
3463
|
+
// ----- panel: newsletters -----
|
|
3464
|
+
const newsletterJson = async (n) => ({ ...n, stats: await newsletter.stats(n.id) });
|
|
3465
|
+
const loadNewsletter = async (id) => {
|
|
3466
|
+
const found = await newsletter.get(id);
|
|
3467
|
+
if (!found)
|
|
3468
|
+
throw new ApiError(404, 'not_found', 'Questa newsletter non esiste');
|
|
3469
|
+
return found;
|
|
3470
|
+
};
|
|
3471
|
+
const editable = (n) => {
|
|
3472
|
+
if (n.status === 'sending' || n.status === 'sent')
|
|
3473
|
+
throw new ApiError(409, 'newsletter_sent', 'Questa newsletter è già partita: non si può più cambiare');
|
|
3474
|
+
};
|
|
3475
|
+
const newsletterBody = z.object({
|
|
3476
|
+
subject: z.string().trim().min(1).max(200),
|
|
3477
|
+
preheader: z.string().max(200).default(''),
|
|
3478
|
+
body: z.array(z.record(z.string(), z.unknown())).max(2000).default([]),
|
|
3479
|
+
});
|
|
3480
|
+
app.get('/api/newsletters', requireUser, async (c) => c.json({ newsletters: await Promise.all((await newsletter.list()).map(newsletterJson)) }));
|
|
3481
|
+
app.post('/api/newsletters', requireUser, async (c) => {
|
|
3482
|
+
const input = await body(c, newsletterBody);
|
|
3483
|
+
const created = await newsletter.create({ ...input, user: c.get('user') });
|
|
3484
|
+
await record(c, 'newsletter.created', created.subject);
|
|
3485
|
+
return c.json(await newsletterJson(created), 201);
|
|
3486
|
+
});
|
|
3487
|
+
app.get('/api/newsletters/:id', requireUser, async (c) => c.json(await newsletterJson(await loadNewsletter(c.req.param('id')))));
|
|
3488
|
+
app.put('/api/newsletters/:id', requireUser, async (c) => {
|
|
3489
|
+
const found = await loadNewsletter(c.req.param('id'));
|
|
3490
|
+
editable(found);
|
|
3491
|
+
const input = await body(c, newsletterBody);
|
|
3492
|
+
await newsletter.update(found.id, input);
|
|
3493
|
+
return c.json(await newsletterJson(await loadNewsletter(found.id)));
|
|
3494
|
+
});
|
|
3495
|
+
app.delete('/api/newsletters/:id', requireUser, async (c) => {
|
|
3496
|
+
const found = await loadNewsletter(c.req.param('id'));
|
|
3497
|
+
if (found.status === 'sending')
|
|
3498
|
+
throw new ApiError(409, 'newsletter_sending', 'La newsletter sta partendo: aspetta che finisca');
|
|
3499
|
+
await newsletter.remove(found.id);
|
|
3500
|
+
await record(c, 'newsletter.deleted', found.subject);
|
|
3501
|
+
return c.body(null, 204);
|
|
3502
|
+
});
|
|
3503
|
+
/** The email for one send: links, picture and unsubscribe address made for that person. */
|
|
3504
|
+
const composeNewsletter = async (n, send) => {
|
|
3505
|
+
const origin = await newsletterOrigin();
|
|
3506
|
+
const s = await newsletterSettings();
|
|
3507
|
+
const tracking = !!send && s.tracking !== false;
|
|
3508
|
+
const secret = tracking ? await newsletterSecret() : '';
|
|
3509
|
+
const unsubscribeUrl = send
|
|
3510
|
+
? `${origin}/api/newsletter/unsubscribe/${send.token}?s=${encodeURIComponent(send.id)}`
|
|
3511
|
+
: `${origin}/api/newsletter/unsubscribe/prova`;
|
|
3512
|
+
const content = {
|
|
3513
|
+
site: ctx.config().site.name,
|
|
3514
|
+
siteUrl: origin,
|
|
3515
|
+
subject: n.subject,
|
|
3516
|
+
preheader: n.preheader,
|
|
3517
|
+
body: n.body,
|
|
3518
|
+
accent: await accentColor(),
|
|
3519
|
+
footer: s.footer ?? '',
|
|
3520
|
+
unsubscribeUrl,
|
|
3521
|
+
locale: send?.locale ?? ctx.config().locales.default,
|
|
3522
|
+
...(tracking && send
|
|
3523
|
+
? {
|
|
3524
|
+
openUrl: `${origin}/api/newsletter/o/${send.id}.gif`,
|
|
3525
|
+
link: (href) => `${origin}/api/newsletter/c/${send.id}?u=${encodeURIComponent(href)}&s=${linkSignature(send.id, href, secret)}`,
|
|
3526
|
+
}
|
|
3527
|
+
: {}),
|
|
3528
|
+
};
|
|
3529
|
+
return {
|
|
3530
|
+
subject: n.subject,
|
|
3531
|
+
html: newsletterHtml(content),
|
|
3532
|
+
text: newsletterText(content),
|
|
3533
|
+
headers: (send
|
|
3534
|
+
? { 'List-Unsubscribe': `<${unsubscribeUrl}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' }
|
|
3535
|
+
: {}),
|
|
3536
|
+
};
|
|
3537
|
+
};
|
|
3538
|
+
const readyMail = async () => {
|
|
3539
|
+
const mail = await settings.get('mail');
|
|
3540
|
+
if (!isConfigured(mail))
|
|
3541
|
+
throw new ApiError(400, 'mail_not_configured', 'Per inviare la newsletter serve l’email: Impostazioni → Email');
|
|
3542
|
+
return mail;
|
|
3543
|
+
};
|
|
3544
|
+
/** A try, to the person writing it (or another address): nothing is counted. */
|
|
3545
|
+
app.post('/api/newsletters/:id/test', requireUser, async (c) => {
|
|
3546
|
+
const found = await loadNewsletter(c.req.param('id'));
|
|
3547
|
+
const input = await body(c, z.object({ to: z.email().optional() }));
|
|
3548
|
+
const mail = await readyMail();
|
|
3549
|
+
await newsletterOrigin(c);
|
|
3550
|
+
const message = await composeNewsletter(found, null);
|
|
3551
|
+
await sendMail(mail, { to: input.to ?? c.get('user').email, ...message, subject: `[Prova] ${message.subject}` });
|
|
3552
|
+
return c.json({ ok: true });
|
|
3553
|
+
});
|
|
3554
|
+
app.post('/api/newsletters/:id/send', requireUser, async (c) => {
|
|
3555
|
+
const found = await loadNewsletter(c.req.param('id'));
|
|
3556
|
+
editable(found);
|
|
3557
|
+
await readyMail();
|
|
3558
|
+
await newsletterOrigin(c);
|
|
3559
|
+
const { counts } = await newsletter.subscribers({ limit: 1 });
|
|
3560
|
+
if (!counts.active)
|
|
3561
|
+
throw new ApiError(400, 'no_subscribers', 'Non ci sono ancora iscritti attivi');
|
|
3562
|
+
await newsletter.setStatus(found.id, 'sending', { runAt: new Date(), error: null });
|
|
3563
|
+
await record(c, 'newsletter.sent', found.subject, { subscribers: counts.active });
|
|
3564
|
+
void runNewsletters();
|
|
3565
|
+
return c.json(await newsletterJson(await loadNewsletter(found.id)));
|
|
3566
|
+
});
|
|
3567
|
+
app.put('/api/newsletters/:id/schedule', requireUser, async (c) => {
|
|
3568
|
+
const found = await loadNewsletter(c.req.param('id'));
|
|
3569
|
+
editable(found);
|
|
3570
|
+
const input = await body(c, z.object({ at: z.iso.datetime({ offset: true }).nullable() }));
|
|
3571
|
+
if (input.at === null) {
|
|
3572
|
+
await newsletter.setStatus(found.id, 'draft', { runAt: null });
|
|
3573
|
+
return c.json(await newsletterJson(await loadNewsletter(found.id)));
|
|
3574
|
+
}
|
|
3575
|
+
const at = new Date(input.at);
|
|
3576
|
+
if (at.getTime() <= Date.now() + 30_000)
|
|
3577
|
+
throw new ApiError(400, 'past_time', 'Scegli un momento nel futuro, almeno tra un minuto');
|
|
3578
|
+
await readyMail();
|
|
3579
|
+
await newsletterOrigin(c);
|
|
3580
|
+
await newsletter.setStatus(found.id, 'scheduled', { runAt: at, error: null });
|
|
3581
|
+
await record(c, 'newsletter.scheduled', found.subject, { at: at.toISOString() });
|
|
3582
|
+
return c.json(await newsletterJson(await loadNewsletter(found.id)));
|
|
3583
|
+
});
|
|
3584
|
+
/** An article becomes a newsletter: its title, its summary, its text and a link to read it on the site. */
|
|
3585
|
+
app.post('/api/newsletters/from-document', requireUser, async (c) => {
|
|
3586
|
+
const input = await body(c, z.object({ id: z.string() }));
|
|
3587
|
+
const doc = await loadDocument(input.id);
|
|
3588
|
+
const def = collection(doc.collection);
|
|
3589
|
+
const data = (doc.publishedData ?? doc.data);
|
|
3590
|
+
const fieldOf = (kind) => Object.entries(def.fields).find(([, f]) => f.kind === kind)?.[0];
|
|
3591
|
+
const rich = fieldOf('richText');
|
|
3592
|
+
const summary = Object.entries(def.fields).find(([n, f]) => f.kind === 'text' && /excerpt|summary|sommario|riassunto|descr/i.test(n))?.[0];
|
|
3593
|
+
const seo = (fieldOf('seo') ? data[fieldOf('seo')] : null);
|
|
3594
|
+
const preheader = (summary ? String(data[summary] ?? '') : '') || seo?.description || '';
|
|
3595
|
+
const blocks = rich && Array.isArray(data[rich]) ? [...data[rich]] : [];
|
|
3596
|
+
const url = (() => {
|
|
3597
|
+
try {
|
|
3598
|
+
return publicUrl(def, doc.locale, data);
|
|
3599
|
+
}
|
|
3600
|
+
catch {
|
|
3601
|
+
return null;
|
|
3602
|
+
}
|
|
3603
|
+
})();
|
|
3604
|
+
if (!blocks.length && preheader)
|
|
3605
|
+
blocks.push({ type: 'paragraph', children: [{ text: preheader }] });
|
|
3606
|
+
if (url)
|
|
3607
|
+
blocks.push({
|
|
3608
|
+
type: 'paragraph',
|
|
3609
|
+
children: [
|
|
3610
|
+
{ text: langOf(doc.locale) === 'it' ? 'Leggi sul sito →' : 'Read it on the site →', href: url, bold: true },
|
|
3611
|
+
],
|
|
3612
|
+
});
|
|
3613
|
+
const created = await newsletter.create({
|
|
3614
|
+
subject: doc.title,
|
|
3615
|
+
preheader: preheader.slice(0, 200),
|
|
3616
|
+
body: blocks,
|
|
3617
|
+
user: c.get('user'),
|
|
3618
|
+
});
|
|
3619
|
+
await record(c, 'newsletter.created', created.subject, { from: doc.id });
|
|
3620
|
+
return c.json(await newsletterJson(created), 201);
|
|
3621
|
+
});
|
|
3622
|
+
/** A draft written by the AI from the latest articles, in the brand voice, with a link to each. */
|
|
3623
|
+
app.post('/api/ai/newsletter', requireUser, async (c) => {
|
|
3624
|
+
const input = await body(c, z.object({ count: z.number().int().min(1).max(10).default(3), note: z.string().max(1000).optional() }));
|
|
3625
|
+
const user = c.get('user');
|
|
3626
|
+
if (!allowAi(user.id))
|
|
3627
|
+
throw new ApiError(429, 'too_many_requests', 'Troppe richieste: aspetta un minuto');
|
|
3628
|
+
const ai = await readyAi();
|
|
3629
|
+
const config = ctx.config();
|
|
3630
|
+
const lang = config.locales.default;
|
|
3631
|
+
const items = [];
|
|
3632
|
+
for (const name of feedCollections(config.collections)) {
|
|
3633
|
+
const def = collection(name);
|
|
3634
|
+
for (const doc of await content.published(name)) {
|
|
3635
|
+
if (doc.locale !== lang)
|
|
3636
|
+
continue;
|
|
3637
|
+
const data = (doc.publishedData ?? {});
|
|
3638
|
+
let url = null;
|
|
3639
|
+
try {
|
|
3640
|
+
url = publicUrl(def, lang, data);
|
|
3641
|
+
}
|
|
3642
|
+
catch { }
|
|
3643
|
+
if (!url)
|
|
3644
|
+
continue;
|
|
3645
|
+
const text = Object.entries(def.fields)
|
|
3646
|
+
.filter(([, f]) => f.kind === 'text' || f.kind === 'richText')
|
|
3647
|
+
.map(([n]) => ((f) => (typeof f === 'string' ? f : Array.isArray(f) ? richTextHtml(f).replace(/<[^>]+>/g, ' ') : ''))(data[n]))
|
|
3648
|
+
.join('\n')
|
|
3649
|
+
.replace(/\s+/g, ' ')
|
|
3650
|
+
.slice(0, 2500);
|
|
3651
|
+
items.push({ title: doc.title, url, text, date: (doc.publishedAt ?? doc.updatedAt).getTime() });
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
items.sort((a, b) => b.date - a.date);
|
|
3655
|
+
const latest = items.slice(0, input.count);
|
|
3656
|
+
if (!latest.length)
|
|
3657
|
+
throw new ApiError(400, 'no_articles', 'Non ci sono ancora articoli pubblicati da raccontare');
|
|
3658
|
+
const voice = await voiceText();
|
|
3659
|
+
const system = `${voice ? `${voice}\n\n` : ''}You write the email newsletter of the website "${config.site.name}", in ${languageName(lang)}. You get its latest articles; write a short newsletter that makes people want to read them: a subject (at most 70 characters), a preheader (one sentence, at most 120 characters), an opening of one or two sentences, then for each article a heading with its title and two or three sentences on why it is worth reading, ending with a link to it. Use only facts from the articles. Reply with JSON only: {"subject": "...", "preheader": "...", "blocks": [{"type": "paragraph"|"heading", "text": "...", "link": {"text": "...", "href": "..."}?}]}.`;
|
|
3660
|
+
const prompt = `${input.note ? `<note>${input.note}</note>\n` : ''}${latest.map((a) => `<article title="${a.title.replace(/"/g, "'")}" url="${a.url}">\n${a.text}\n</article>`).join('\n')}`;
|
|
3661
|
+
const reply = await complete(ai, { system, prompt }).catch(aiFailed);
|
|
3662
|
+
let parsed;
|
|
3663
|
+
try {
|
|
3664
|
+
parsed = JSON.parse(reply.replace(/^```(?:json)?\s*|\s*```$/g, ''));
|
|
3665
|
+
}
|
|
3666
|
+
catch {
|
|
3667
|
+
throw new ApiError(422, 'ai_failed', 'La risposta dell’AI non era leggibile: riprova');
|
|
3668
|
+
}
|
|
3669
|
+
const known = new Set(latest.map((a) => a.url));
|
|
3670
|
+
const blocks = [];
|
|
3671
|
+
for (const b of parsed.blocks ?? []) {
|
|
3672
|
+
const text = (b.text ?? '').trim();
|
|
3673
|
+
const children = text ? [{ text }] : [];
|
|
3674
|
+
if (b.link?.href && known.has(b.link.href) && b.link.text)
|
|
3675
|
+
children.push({ text: `${text ? ' ' : ''}${b.link.text.trim()}`, href: b.link.href, bold: true });
|
|
3676
|
+
if (!children.length)
|
|
3677
|
+
continue;
|
|
3678
|
+
blocks.push(b.type === 'heading' ? { type: 'heading', level: 2, children } : { type: 'paragraph', children });
|
|
3679
|
+
}
|
|
3680
|
+
const created = await newsletter.create({
|
|
3681
|
+
subject: (parsed.subject ?? '').trim().slice(0, 200) || latest[0]?.title || config.site.name,
|
|
3682
|
+
preheader: (parsed.preheader ?? '').trim().slice(0, 200),
|
|
3683
|
+
body: blocks,
|
|
3684
|
+
user,
|
|
3685
|
+
});
|
|
3686
|
+
await record(c, 'newsletter.created', created.subject, { ai: true });
|
|
3687
|
+
return c.json(await newsletterJson(created), 201);
|
|
3688
|
+
});
|
|
3689
|
+
/**
|
|
3690
|
+
* Sends what is due: scheduled newsletters whose time has come and those a stopped server left
|
|
3691
|
+
* halfway. One email at a time, a short pause between them, so the email service keeps up.
|
|
3692
|
+
*/
|
|
3693
|
+
let newslettersRunning = false;
|
|
3694
|
+
const runNewsletters = async (now = new Date(), pause = 120) => {
|
|
3695
|
+
if (newslettersRunning)
|
|
3696
|
+
return 0;
|
|
3697
|
+
newslettersRunning = true;
|
|
3698
|
+
let sent = 0;
|
|
3699
|
+
try {
|
|
3700
|
+
for (const n of await newsletter.due(now)) {
|
|
3701
|
+
const mail = await settings.get('mail');
|
|
3702
|
+
if (!isConfigured(mail)) {
|
|
3703
|
+
await newsletter.setStatus(n.id, 'failed', { error: 'L’email non è configurata' });
|
|
3704
|
+
continue;
|
|
3705
|
+
}
|
|
3706
|
+
if (n.status === 'scheduled')
|
|
3707
|
+
await newsletter.setStatus(n.id, 'sending');
|
|
3708
|
+
await newsletter.prepare(n.id);
|
|
3709
|
+
for (;;) {
|
|
3710
|
+
const batch = await newsletter.pending(n.id, 50);
|
|
3711
|
+
if (!batch.length)
|
|
3712
|
+
break;
|
|
3713
|
+
for (const item of batch) {
|
|
3714
|
+
const who = await newsletter.subscriber(item.subscriberId);
|
|
3715
|
+
if (!who || who.status !== 'active') {
|
|
3716
|
+
await newsletter.markSend(item.id, 'failed', 'Non più iscritto');
|
|
3717
|
+
continue;
|
|
3718
|
+
}
|
|
3719
|
+
try {
|
|
3720
|
+
const message = await composeNewsletter(n, { id: item.id, token: who.token, locale: who.locale });
|
|
3721
|
+
await sendMail(mail, { to: who.email, ...message });
|
|
3722
|
+
await newsletter.markSend(item.id, 'sent');
|
|
3723
|
+
sent++;
|
|
3724
|
+
}
|
|
3725
|
+
catch (error) {
|
|
3726
|
+
await newsletter.markSend(item.id, 'failed', error instanceof Error ? error.message.slice(0, 300) : 'Errore');
|
|
3727
|
+
}
|
|
3728
|
+
if (pause)
|
|
3729
|
+
await new Promise((r) => setTimeout(r, pause));
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
const stats = await newsletter.stats(n.id);
|
|
3733
|
+
const allFailed = stats.total > 0 && stats.sent === 0;
|
|
3734
|
+
await newsletter.setStatus(n.id, allFailed ? 'failed' : 'sent', {
|
|
3735
|
+
sentAt: new Date(),
|
|
3736
|
+
error: allFailed ? 'Nessun invio riuscito: controlla Impostazioni → Email' : null,
|
|
3737
|
+
});
|
|
3738
|
+
}
|
|
3739
|
+
// Signups never confirmed are dropped after a week.
|
|
3740
|
+
await newsletter.dropUnconfirmed(new Date(now.getTime() - 7 * 86_400_000));
|
|
3741
|
+
}
|
|
3742
|
+
catch (error) {
|
|
3743
|
+
console.error('Materialize: newsletter sending failed', error);
|
|
3744
|
+
}
|
|
3745
|
+
finally {
|
|
3746
|
+
newslettersRunning = false;
|
|
3747
|
+
}
|
|
3748
|
+
return sent;
|
|
3749
|
+
};
|
|
3750
|
+
setInterval(() => void runNewsletters(), 30_000).unref?.();
|
|
2651
3751
|
// ---------- shared preview links ----------
|
|
2652
3752
|
const previewJson = (c, link) => {
|
|
2653
3753
|
const path = `/_materialize/share/${link.token}`;
|
|
@@ -2692,6 +3792,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2692
3792
|
const existing = await loadDocument(c.req.param('id'));
|
|
2693
3793
|
await content.remove(existing.id);
|
|
2694
3794
|
await schedules.removeForDocument(existing.id);
|
|
3795
|
+
await releases.removeDocument(existing.id);
|
|
2695
3796
|
await previewLinks.removeForDocument(existing.id);
|
|
2696
3797
|
if (existing.publishedAt)
|
|
2697
3798
|
sitePublished();
|
|
@@ -2761,7 +3862,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2761
3862
|
slots = only(slots);
|
|
2762
3863
|
if (!slots.length)
|
|
2763
3864
|
return;
|
|
2764
|
-
const translated = await translateTexts(ai, slots.map((s) => s.text), from, to).catch(aiFailed);
|
|
3865
|
+
const translated = await translateTexts(ai, slots.map((s) => s.text), from, to, undefined, await voiceText('style')).catch(aiFailed);
|
|
2765
3866
|
for (const [i, slot] of slots.entries())
|
|
2766
3867
|
slot.set(translated[i]);
|
|
2767
3868
|
};
|
|
@@ -2838,7 +3939,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2838
3939
|
const own = nodeSlots(walk(data[name]));
|
|
2839
3940
|
if (!own.length)
|
|
2840
3941
|
continue;
|
|
2841
|
-
const translated = await translateTexts(ai, own.map((s) => s.text), original.locale, doc.locale).catch(aiFailed);
|
|
3942
|
+
const translated = await translateTexts(ai, own.map((s) => s.text), original.locale, doc.locale, undefined, await voiceText('style')).catch(aiFailed);
|
|
2842
3943
|
for (const [i, slot] of own.entries())
|
|
2843
3944
|
slot.set(translated[i]);
|
|
2844
3945
|
}
|
|
@@ -2944,6 +4045,60 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2944
4045
|
])),
|
|
2945
4046
|
});
|
|
2946
4047
|
});
|
|
4048
|
+
/** The caller's credentials, for tools that go through the API as them. */
|
|
4049
|
+
const credentialsOf = (c) => {
|
|
4050
|
+
const authorization = c.req.header('authorization');
|
|
4051
|
+
const cookie = c.req.header('cookie');
|
|
4052
|
+
return authorization ? { authorization } : cookie ? { cookie } : {};
|
|
4053
|
+
};
|
|
4054
|
+
/**
|
|
4055
|
+
* An imported page laid out again by the AI with the section library, in its draft. The texts
|
|
4056
|
+
* and images must stay the same (checked after): otherwise the page is left as it was.
|
|
4057
|
+
*/
|
|
4058
|
+
const relayoutPage = async (ai, id, user, credentials) => {
|
|
4059
|
+
const doc = await content.get(id);
|
|
4060
|
+
if (!doc)
|
|
4061
|
+
return 'la pagina non c’è più';
|
|
4062
|
+
const def = collection(doc.collection);
|
|
4063
|
+
const field = Object.entries(def.fields).find(([, f]) => f.kind === 'page')?.[0];
|
|
4064
|
+
const before = field && Array.isArray(doc.data[field]) ? doc.data[field] : null;
|
|
4065
|
+
if (!field || !before?.length)
|
|
4066
|
+
return 'la pagina è vuota';
|
|
4067
|
+
const page = { nodes: before };
|
|
4068
|
+
const call = apiCaller(app.fetch, credentials);
|
|
4069
|
+
const getSchema = () => call('GET', '/api/schema');
|
|
4070
|
+
const tools = [
|
|
4071
|
+
...contentTools(call, getSchema).filter((t) => ['list_library', 'get_theme'].includes(t.name)),
|
|
4072
|
+
...pageTools({
|
|
4073
|
+
withId: false,
|
|
4074
|
+
brand: async () => ctx.config().site.name,
|
|
4075
|
+
source: async () => ({
|
|
4076
|
+
load: async () => page.nodes,
|
|
4077
|
+
save: async (nodes) => {
|
|
4078
|
+
page.nodes = nodes;
|
|
4079
|
+
},
|
|
4080
|
+
}),
|
|
4081
|
+
}),
|
|
4082
|
+
];
|
|
4083
|
+
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.
|
|
4084
|
+
|
|
4085
|
+
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.
|
|
4086
|
+
|
|
4087
|
+
Rules:
|
|
4088
|
+
- 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.
|
|
4089
|
+
- Keep every image (same src and alt) and every button with its link.
|
|
4090
|
+
- 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.
|
|
4091
|
+
- Do not add a menu or a footer: the site has its own.
|
|
4092
|
+
- Keep it simple: a clear opening, then sections that follow the page's order.
|
|
4093
|
+
When done, reply with one short sentence.`;
|
|
4094
|
+
await runTurn(ai, { provider: ai.provider, messages: [] }, { system, text: `Lay out the page "${doc.title}".`, tools, onEvent: () => { }, maxSteps: 30 });
|
|
4095
|
+
const problem = relayoutProblem(before, page.nodes);
|
|
4096
|
+
if (problem)
|
|
4097
|
+
return problem;
|
|
4098
|
+
const draft = draftData(def, { ...doc.data, [field]: restoreAlts(before, page.nodes).map(plain) });
|
|
4099
|
+
await content.update(doc.id, { ...draft, userId: user.id });
|
|
4100
|
+
return null;
|
|
4101
|
+
};
|
|
2947
4102
|
app.post('/api/import/wordpress/run', requireUser, requireSession, requireAdmin, async (c) => {
|
|
2948
4103
|
const target = z.object({ collection: z.string(), fields: z.record(z.string(), z.string()) }).nullable();
|
|
2949
4104
|
const input = await body(c, z.object({
|
|
@@ -2956,9 +4111,11 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2956
4111
|
locale: z.string().optional(),
|
|
2957
4112
|
categories: z.record(z.string(), z.string()).optional(),
|
|
2958
4113
|
fallbackCategory: z.string().optional(),
|
|
4114
|
+
aiLayout: z.boolean().optional(),
|
|
2959
4115
|
}),
|
|
2960
4116
|
}));
|
|
2961
4117
|
const held = wpSites.get(input.token);
|
|
4118
|
+
const ai = input.plan.aiLayout ? await readyAi() : null;
|
|
2962
4119
|
if (!held)
|
|
2963
4120
|
throw new ApiError(409, 'import_expired', 'Read the site again');
|
|
2964
4121
|
const lang = locale(input.plan.locale);
|
|
@@ -2977,6 +4134,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
2977
4134
|
problems: [],
|
|
2978
4135
|
media: { downloaded: 0, failed: 0, reasons: {} },
|
|
2979
4136
|
redirects: 0,
|
|
4137
|
+
relaid: 0,
|
|
2980
4138
|
at: Date.now(),
|
|
2981
4139
|
};
|
|
2982
4140
|
wpJobs.set(id, job);
|
|
@@ -3067,6 +4225,7 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3067
4225
|
return list.length - before;
|
|
3068
4226
|
},
|
|
3069
4227
|
onProgress: (p) => Object.assign(job, p),
|
|
4228
|
+
relayout: ai ? (doc) => relayoutPage(ai, doc.id, user, credentialsOf(c)) : undefined,
|
|
3070
4229
|
};
|
|
3071
4230
|
void runImport(held.site, plan, deps)
|
|
3072
4231
|
.then(async (result) => {
|
|
@@ -3078,7 +4237,12 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3078
4237
|
userName: user.name,
|
|
3079
4238
|
action: 'import.wordpress',
|
|
3080
4239
|
target: held.site.name,
|
|
3081
|
-
detail: {
|
|
4240
|
+
detail: {
|
|
4241
|
+
created: result.created.length,
|
|
4242
|
+
media: result.media.downloaded,
|
|
4243
|
+
redirects: result.redirects,
|
|
4244
|
+
...(result.relaid ? { relaid: result.relaid } : {}),
|
|
4245
|
+
},
|
|
3082
4246
|
ip: null,
|
|
3083
4247
|
});
|
|
3084
4248
|
})
|
|
@@ -3260,5 +4424,5 @@ When done, say in one or two short sentences what you changed. Reply in the pers
|
|
|
3260
4424
|
return c.json(publicDocument(doc));
|
|
3261
4425
|
});
|
|
3262
4426
|
// runSchedules: for the tests, which cannot wait for the clock.
|
|
3263
|
-
return Object.assign(app, { runSchedules });
|
|
4427
|
+
return Object.assign(app, { runSchedules, runNewsletters });
|
|
3264
4428
|
}
|