diff --git a/admin/src/components/form/formState.ts b/admin/src/components/form/formState.ts
index 0ecc0c4..38df095 100644
--- a/admin/src/components/form/formState.ts
+++ b/admin/src/components/form/formState.ts
@@ -92,6 +92,91 @@ export function presetValue(type: string, source: unknown): string {
.replace(/^-+|-+$/g, '')
}
+/** The locales whose text differs between two locale maps: every locale on
+ * either side, a missing key counting as ''. A value that is not a map counts
+ * as an empty map. */
+export function changedLocales(previous: unknown, next: unknown): string[] {
+ const before = localeRecord(previous)
+ const after = localeRecord(next)
+ const out: string[] = []
+ for (const locale of new Set([...Object.keys(before), ...Object.keys(after)])) {
+ if ((before[locale] ?? '') !== (after[locale] ?? '')) {
+ out.push(locale)
+ }
+ }
+ return out
+}
+
+/** Reports whether the administrator edited a preset target by hand: the
+ * whole field when no locale is given, one locale of an ML target otherwise. */
+export type PresetEdited = (target: string, locale?: string) => boolean
+
+function isMLType(type: string | undefined): boolean {
+ return type === 'mltext' || type === 'mlmarkdown'
+}
+
+/**
+ * The preset targets of `source` (fields.yaml `preset`) to rewrite after the
+ * source changed from `previous` to `next`: target name -> new value. Targets
+ * are text and mltext fields; a preset on any other type is ignored.
+ *
+ * - text -> text: the target takes presetValue of the source, until edited.
+ * - ML -> text: the target takes presetValue of the source's active-locale text.
+ * - text -> ML: only the target's active locale is written; its other locales stay.
+ * - ML -> ML: per locale. Each locale whose source text changed rewrites the
+ * same locale of the target, unless the administrator edited that locale by
+ * hand; the target's other locales stay.
+ *
+ * A preset never writes a locale the administrator neither typed in (source)
+ * nor sees (target), so untouched locales keep the server's fallback.
+ */
+export function presetUpdates(
+ fields: FormField[],
+ source: string,
+ previous: unknown,
+ next: unknown,
+ values: AdminRecord,
+ activeLocale: string,
+ isEdited: PresetEdited,
+): AdminRecord {
+ const out: AdminRecord = {}
+ const sourceML = isMLType(fields.find((field) => field.name === source)?.type)
+ for (const field of fields) {
+ const preset = field.preset
+ if (!preset || preset.field !== source || (field.type !== 'text' && field.type !== 'mltext')) {
+ continue
+ }
+ if (field.type === 'text') {
+ if (isEdited(field.name)) {
+ continue
+ }
+ out[field.name] = presetValue(preset.type, sourceML ? (localeRecord(next)[activeLocale] ?? '') : next)
+ continue
+ }
+ if (!sourceML) {
+ if (isEdited(field.name, activeLocale)) {
+ continue
+ }
+ out[field.name] = { ...localeRecord(values[field.name]), [activeLocale]: presetValue(preset.type, next) }
+ continue
+ }
+ const texts = localeRecord(next)
+ const target = { ...localeRecord(values[field.name]) }
+ let written = false
+ for (const locale of changedLocales(previous, next)) {
+ if (isEdited(field.name, locale)) {
+ continue
+ }
+ target[locale] = presetValue(preset.type, texts[locale] ?? '')
+ written = true
+ }
+ if (written) {
+ out[field.name] = target
+ }
+ }
+ return out
+}
+
/** Initial values of a new record: schema defaults, toggles off, no ids.
* ML fields seed an empty string for every enabled locale. */
export function initialValues(fields: FormField[], enabledLocales: readonly string[] = []): AdminRecord {
diff --git a/admin/src/views/FormView.vue b/admin/src/views/FormView.vue
index 785b1c8..313b313 100644
--- a/admin/src/views/FormView.vue
+++ b/admin/src/views/FormView.vue
@@ -1,5 +1,5 @@
+