feat: let each translation list its own placeholder fill-in words

This commit is contained in:
Linus Rath
2026-10-27 23:03:41 +01:00
parent 087cc68f6b
commit be5fc12622
32 changed files with 307 additions and 94 deletions
@@ -4,6 +4,29 @@ import React from 'react';
import { EmailComposer } from '../email-composer';
import { useSettingsStore } from '@/stores/settings-store';
// The UI language whose fill-in words t.raw hands the composer.
const ui = vi.hoisted(() => ({ locale: 'en' }));
vi.mock('next-intl', async () => {
const fs = await import('fs');
const path = await import('path');
const messages = (locale: string) =>
JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../../locales', locale, 'common.json'), 'utf-8'));
return {
useTranslations: (namespace?: string) => {
const t = (key: string) => key;
t.raw = (key: string) => (namespace ? messages(ui.locale)[namespace] : messages(ui.locale))[key];
return t;
},
useLocale: () => ui.locale,
useFormatter: () => ({
dateTime: (d: Date | string) => String(d),
relativeTime: (d: Date | string) => String(d),
number: (n: number) => String(n),
}),
};
});
// ─── Heavy component mocks (mirrors empty-subject-warning.test.tsx) ──────────
vi.mock('@/components/email/rich-text-editor', () => ({
@@ -207,6 +230,7 @@ describe('composer placeholder warning', () => {
});
afterEach(() => {
ui.locale = 'en';
useSettingsStore.setState({
placeholderWarningEnabled: true,
attachmentReminderEnabled: false,
@@ -319,6 +343,29 @@ describe('composer placeholder warning', () => {
expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
});
it('uses the fill-in words of the UI language', async () => {
ui.locale = 'de';
const onSend = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT_WITH_PLACEHOLDER, body: '<p>Viele Grüße, [Ihr Name]</p>' }} onSend={onSend} />);
fireEvent.click(sendButton());
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('[Ihr Name]')).toBeInTheDocument();
expect(onSend).not.toHaveBeenCalled();
});
it('keeps the English fill-in words in another UI language', async () => {
ui.locale = 'de';
const onSend = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT_WITH_PLACEHOLDER, body: '<p>Thanks, [Your Name]</p>' }} onSend={onSend} />);
fireEvent.click(sendButton());
const dialog = await screen.findByRole('alertdialog');
expect(within(dialog).getByText('[Your Name]')).toBeInTheDocument();
});
it('sends without asking when the warning is off', async () => {
useSettingsStore.setState({ placeholderWarningEnabled: false } as never);
const onSend = vi.fn();
+7 -1
View File
@@ -90,6 +90,7 @@ import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from "@/lib/placeholder-check";
import enMessages from "@/locales/en/common.json";
import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
@@ -3237,7 +3238,12 @@ export function EmailComposer({
plainTextMode,
forwardedSeparator: tQuote('forwarded_separator'),
});
const found = findUnfilledPlaceholders(subject, bodyText);
// The fill-in words of the UI language, and of English: AI drafts and
// templates are often English whatever the UI language is.
const found = findUnfilledPlaceholders(subject, bodyText, [
enMessages.email_composer.placeholder_words,
t.raw('placeholder_words'),
]);
if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) {
setPlaceholderWarningMatches(found);
setPlaceholderDontAskAgain(false);
+51 -10
View File
@@ -1,5 +1,20 @@
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml } from '../placeholder-check';
import { findUnfilledPlaceholders, stripEmbeddedSignatureHtml, type PlaceholderWords } from '../placeholder-check';
const localesDir = path.resolve(__dirname, '../../locales');
const locales = fs.readdirSync(localesDir).filter((l) => fs.existsSync(path.join(localesDir, l, 'common.json')));
function wordsOf(locale: string): PlaceholderWords {
const messages = JSON.parse(fs.readFileSync(path.join(localesDir, locale, 'common.json'), 'utf-8'));
return messages.email_composer.placeholder_words;
}
// What the composer passes: English and the UI language.
const withUi = (locale: string) => [wordsOf('en'), wordsOf(locale)];
const EN = withUi('en');
const ALL = locales.map(wordsOf);
describe('findUnfilledPlaceholders', () => {
it('finds template placeholders in the subject and the body', () => {
@@ -17,11 +32,11 @@ describe('findUnfilledPlaceholders', () => {
});
it('finds fill-ins that ask the sender to write something', () => {
expect(findUnfilledPlaceholders('', 'Best regards,\n[Your Name]\n[Insert date]')).toEqual([
expect(findUnfilledPlaceholders('', 'Best regards,\n[Your Name]\n[Insert date]', EN)).toEqual([
'[Your Name]',
'[Insert date]',
]);
expect(findUnfilledPlaceholders('', 'Viele Grüße\n[Ihr Name], [Datum einfügen]')).toEqual([
expect(findUnfilledPlaceholders('', 'Viele Grüße\n[Ihr Name], [Datum einfügen]', withUi('de'))).toEqual([
'[Ihr Name]',
'[Datum einfügen]',
]);
@@ -64,8 +79,22 @@ describe('findUnfilledPlaceholders', () => {
['zh', '[您的姓名]'],
['zh', '【插入日期】'],
['zh-TW', '[填寫公司名稱]'],
])('finds a %s fill-in: %s', (_locale, fillIn) => {
expect(findUnfilledPlaceholders('', `Text ${fillIn} text`)).toEqual([fillIn]);
])('finds a %s fill-in with that UI language: %s', (locale, fillIn) => {
expect(findUnfilledPlaceholders('', `Text ${fillIn} text`, withUi(locale))).toEqual([fillIn]);
});
it('checks only the languages it is given', () => {
expect(findUnfilledPlaceholders('', 'Cordialement, [Votre nom]', withUi('de'))).toEqual([]);
expect(findUnfilledPlaceholders('', 'Cordialement, [Votre nom]', withUi('fr'))).toEqual(['[Votre nom]']);
});
it('finds English fill-ins whatever the UI language', () => {
expect(findUnfilledPlaceholders('', 'Thanks, [Your Name]. TODO', withUi('ja'))).toEqual(['[Your Name]', 'TODO']);
});
it('skips word lists that are not lists of strings', () => {
const broken = [{ before: 'your' }, { before: [1, '', '-', null] }, null, { after: {} }] as unknown as PlaceholderWords[];
expect(findUnfilledPlaceholders('', '[Your Name] [TIME]', broken)).toEqual(['[TIME]']);
});
it('finds the circles that stand in for a name in Japanese and Korean', () => {
@@ -80,11 +109,11 @@ describe('findUnfilledPlaceholders', () => {
'Siehe [Anlage 2] und [Kapitel 3].',
'Termin: ○ und ◯.',
].join('\n');
expect(findUnfilledPlaceholders('', text)).toEqual([]);
expect(findUnfilledPlaceholders('', text, ALL)).toEqual([]);
});
it('finds unfinished-text markers', () => {
expect(findUnfilledPlaceholders('', 'The venue is TBD. TODO: add the agenda. Room XXX')).toEqual([
expect(findUnfilledPlaceholders('', 'The venue is TBD. TODO: add the agenda. Room XXX', EN)).toEqual([
'TBD',
'TODO',
'XXX',
@@ -92,7 +121,7 @@ describe('findUnfilledPlaceholders', () => {
});
it('reports a marker in brackets once', () => {
expect(findUnfilledPlaceholders('', 'Agenda: [TODO]')).toEqual(['[TODO]']);
expect(findUnfilledPlaceholders('', 'Agenda: [TODO]', EN)).toEqual(['[TODO]']);
});
it('lists each placeholder once', () => {
@@ -112,11 +141,23 @@ describe('findUnfilledPlaceholders', () => {
'Signed, [Yours truly].',
'A {single} brace and {{}} are not placeholders.',
].join('\n');
expect(findUnfilledPlaceholders('', text)).toEqual([]);
expect(findUnfilledPlaceholders('', text, ALL)).toEqual([]);
});
it('ignores bracket tags and markers in the subject', () => {
expect(findUnfilledPlaceholders('Re: [EXTERNAL] Venue TBD', 'Thanks!')).toEqual([]);
expect(findUnfilledPlaceholders('Re: [EXTERNAL] Venue TBD', 'Thanks!', ALL)).toEqual([]);
});
});
describe('placeholder words in the locale files', () => {
it.each(locales)('%s has lists of strings', (locale) => {
const words = wordsOf(locale) as Record<string, unknown>;
for (const key of ['before', 'after', 'unfinished']) {
expect(Array.isArray(words[key]), `${locale}: ${key} is not a list`).toBe(true);
for (const word of words[key] as unknown[]) {
expect(typeof word === 'string' && word.replace(/-/g, '').trim() !== '', `${locale}: ${JSON.stringify(word)}`).toBe(true);
}
}
});
});
+62 -83
View File
@@ -10,97 +10,70 @@ const TEMPLATE_PLACEHOLDER = /\{\{\s*[\w.-]+\s*\}\}/g;
// Markdown link.
const CAPS_BRACKET = /\[[\p{Lu}\d][\p{Lu}\d _./-]{0,28}[\p{Lu}\d]\](?!\()/gu;
// Fill-ins that ask the sender for something: [Your Name], [Insert date],
// [Votre nom], [Ваше имя], [您的姓名]. Matched by the word that opens or
// closes them, for the languages the app is translated into. "Your" and
// "insert/enter/add" are what AI drafts and templates use.
const PROMPT_START_WORDS = [
// English
'your', 'insert', 'enter', 'add',
// German
'ihr', 'ihre', 'ihren', 'dein', 'deine', 'deinen',
// French
'votre', 'vos', 'ton', 'ta', 'tes', 'insérer', 'insérez', 'ajouter', 'ajoutez', 'saisir', 'saisissez', 'indiquer', 'indiquez',
// Spanish
'tu', 'tus', 'su', 'sus', 'insertar', 'inserte', 'inserta', 'añadir', 'añade', 'agregar', 'agrega', 'escribe', 'escriba', 'indica', 'indique',
// Portuguese
'seu', 'sua', 'seus', 'suas', 'teu', 'tua', 'inserir', 'insira', 'adicionar', 'adicione', 'digite', 'informe',
// Italian
'il tuo', 'la tua', 'i tuoi', 'le tue', 'il suo', 'la sua', 'inserisci', 'inserire', 'aggiungi', 'aggiungere', 'indica',
// Catalan
'el teu', 'la teva', 'el vostre', 'la vostra', 'insereix', 'inseriu', 'afegeix', 'afegiu', 'escriviu',
// Dutch
'je', 'jouw', 'uw', 'voeg', 'vul',
// Danish, Norwegian
'din', 'dit', 'ditt', 'dine', 'indsæt', 'tilføj', 'sett inn', 'legg til', 'skriv',
// Polish
'twoje', 'twój', 'twoja', 'pana', 'pani', 'wstaw', 'wpisz', 'dodaj', 'podaj',
// Czech, Slovak
'vaše', 'váš', 'vaši', 'vaša', 'tvoje', 'tvůj', 'vložte', 'doplňte', 'zadejte', 'zadajte', 'uveďte',
// Russian, Ukrainian
'ваше', 'ваш', 'ваша', 'ваши', 'ваші', 'твоё', 'твое', 'твой', 'твоя', 'твоє', 'твій',
'вставьте', 'укажите', 'введите', 'добавьте', 'вставте', 'вкажіть', 'введіть', 'додайте',
// Romanian
'introduceți', 'inserați', 'adăugați', 'completați',
// Hungarian
'az ön', 'a te', 'írja be', 'illessze be', 'adja meg',
// Estonian, Latvian
'teie', 'sinu', 'sisesta', 'sisestage', 'lisa', 'lisage',
'jūsu', 'tavs', 'tava', 'ievadiet', 'ierakstiet', 'ievietojiet', 'pievienojiet', 'norādiet',
// Turkish
'şirketinizin', 'buraya',
// Mongolian
'таны', 'чиний',
// Korean
'귀하의', '당신의', '고객님의',
// Arabic, Hebrew
'أدخل', 'ادخل', 'أضف', 'اكتب', 'הכנס', 'הכניסו', 'הזן', 'הוסף',
];
// Languages that put the verb or "your" last: [Datum einfügen],
// [Numele tău], [נושא שלך], [نام شما], [이름 입력].
const PROMPT_END_WORDS = [
'einfügen', 'eintragen', 'ergänzen',
'invoegen', 'invullen', 'toevoegen',
'tău', 'ta', 'dvs.', 'dumneavoastră',
'ekleyin', 'girin', 'yazın',
'оруулна уу',
'שלך', 'שלכם',
'شما', 'وارد کنید', 'اضافه کنید',
'입력', '삽입', '추가',
];
// Japanese and Chinese have no spaces between words: [あなたの名前],
// [名前を入力], [您的姓名], [插入日期].
const PROMPT_START_UNSPACED = ['あなたの', '貴社の', '御社の', '您的', '你的', '贵公司', '貴公司', '插入', '输入', '輸入', '填写', '填寫', '添加', '新增', '在此'];
const PROMPT_END_UNSPACED = ['を入力', 'を記入', 'を挿入', 'を追加', 'を入れる'];
// Turkish possessive "your": [Adınız], [Şirket adınız].
const TURKISH_YOUR = '\\p{L}*[ıiuü]n[ıiuü]z';
/**
* The words that mark a fill-in in one language. They live in each locale
* file under email_composer.placeholder_words, so a new translation brings
* its own.
*
* - `before` opens a fill-in: "your" in [Your name], "insert" in [Insert date].
* - `after` closes one, for languages that put the verb or "your" last:
* "einfügen" in [Datum einfügen].
* - A "-" on the joining side means the word is written onto the next one
* without a space: "您的-" for [您的姓名], "-ınız" for [Adınız].
* - `unfinished` are words in capitals that mark unfinished text, like TODO.
* They match as written, so "todo" in a sentence does not count.
*/
export interface PlaceholderWords {
before?: unknown;
after?: unknown;
unfinished?: unknown;
}
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const alternation = (words: string[]) => words.map(escapeRegExp).join('|');
// The lists come from locale files that contributors edit, so anything that
// is not a list of strings is skipped.
function wordsOf(lists: PlaceholderWords[], key: keyof PlaceholderWords): string[] {
const words = new Set<string>();
for (const list of lists) {
const value = list?.[key];
if (!Array.isArray(value)) continue;
for (const word of value) {
if (typeof word === 'string' && word.replace(/-/g, '').trim()) words.add(word.trim());
}
}
return Array.from(words);
}
// Plain brackets, and the full-width and lenticular ones of CJK text.
const OPEN = '[\\[[【]';
const CLOSE = '[\\]]】]';
const INNER = '[^\\[\\][]【】\\n]';
const PROMPT_BRACKET = new RegExp(
`${OPEN}(?:` + [
`(?:${alternation(PROMPT_START_WORDS)})\\s${INNER}{1,40}`,
`${INNER}{1,40}\\s(?:${alternation(PROMPT_END_WORDS)})`,
`(?:${alternation(PROMPT_START_UNSPACED)})${INNER}{1,40}`,
`${INNER}{1,40}(?:${alternation(PROMPT_END_UNSPACED)})`,
`(?:${INNER}{0,40}\\s)?${TURKISH_YOUR}`,
].join('|') + `)${CLOSE}(?!\\()`,
'giu',
);
function promptBracketPattern(lists: PlaceholderWords[]): RegExp | null {
const before = wordsOf(lists, 'before');
const after = wordsOf(lists, 'after');
const branches: string[] = [];
const add = (words: string[], branch: (alt: string) => string) => {
if (words.length > 0) branches.push(branch(alternation(words)));
};
add(before.filter((w) => !w.endsWith('-')), (alt) => `(?:${alt})\\s${INNER}{1,40}`);
add(before.filter((w) => w.endsWith('-')).map((w) => w.slice(0, -1)), (alt) => `(?:${alt})${INNER}{1,40}`);
add(after.filter((w) => !w.startsWith('-')), (alt) => `${INNER}{1,40}\\s(?:${alt})`);
add(after.filter((w) => w.startsWith('-')).map((w) => w.slice(1)), (alt) => `${INNER}{1,40}(?:${alt})`);
if (branches.length === 0) return null;
return new RegExp(`${OPEN}(?:${branches.join('|')})${CLOSE}(?!\\()`, 'giu');
}
// Upper case only: "todo" or "tba" in a sentence is usually meant.
const UNFINISHED_WORD = /(?<![\p{L}\d_])(?:TODO|TBD|TBA|TBC|FIXME|XXX)(?![\p{L}\d_])/gu;
function unfinishedWordPattern(lists: PlaceholderWords[]): RegExp | null {
const words = wordsOf(lists, 'unfinished');
if (words.length === 0) return null;
return new RegExp(`(?<![\\p{L}\\d_])(?:${alternation(words)})(?![\\p{L}\\d_])`, 'gu');
}
// Japanese and Korean stand-ins for a name or number: 〇〇様, ○○○ 님.
// Japanese and Korean stand-ins for a name or number: 〇〇様, ○○○ 님. Signs
// rather than words, so they are not in the locale files.
const CIRCLE_MARKER = /[〇○◯]{2,}/gu;
const SIGNATURE_START_SELECTOR = '[data-signature-block="separator"], [data-signature-block="start"]';
@@ -136,12 +109,18 @@ function collect(text: string, patterns: RegExp[]): Match[] {
* subject is only checked for template placeholders: it often carries tags
* like [EXTERNAL] or words from the original message, which the sender did
* not write. `bodyText` should be the text the sender wrote, without quoted
* replies and the signature.
* replies and the signature. `words` are the fill-in words of the languages
* to check (see PlaceholderWords).
*/
export function findUnfilledPlaceholders(subject: string, bodyText: string): string[] {
export function findUnfilledPlaceholders(subject: string, bodyText: string, words: PlaceholderWords[] = []): string[] {
const bodyPatterns = [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, CIRCLE_MARKER];
const prompt = promptBracketPattern(words);
const unfinished = unfinishedWordPattern(words);
if (prompt) bodyPatterns.push(prompt);
if (unfinished) bodyPatterns.push(unfinished);
const found = [
...collect(subject, [TEMPLATE_PLACEHOLDER]),
...collect(bodyText, [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, PROMPT_BRACKET, UNFINISHED_WORD, CIRCLE_MARKER]),
...collect(bodyText, bodyPatterns),
].map((m) => m.text);
return Array.from(new Set(found));
}
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "الإرسال على أي حال",
"back": "العودة إلى التعديل"
},
"placeholder_words": {
"before": ["أدخل", "ادخل", "أضف", "اكتب"],
"after": [],
"unfinished": []
},
"schedule_send": "جدولة الإرسال",
"schedule_send_description": "اختر متى يجب على الخادم إرسال هذه الرسالة.",
"schedule_preset": {
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "Envia igualment",
"back": "Torna a l'edició"
},
"placeholder_words": {
"before": ["el teu", "la teva", "el vostre", "la vostra", "insereix", "inseriu", "afegeix", "afegiu", "escriviu"],
"after": [],
"unfinished": []
},
"schedule_send": "Programa l'enviament",
"schedule_send_description": "Trieu quan ha d'alliberar el servidor aquest missatge.",
"schedule_preset": {
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Přesto odeslat",
"back": "Zpět k úpravám"
},
"placeholder_words": {
"before": ["vaše", "váš", "vaši", "tvoje", "tvůj", "vložte", "doplňte", "zadejte", "uveďte"],
"after": [],
"unfinished": []
},
"add_link": "Přidat odkaz",
"link_url_prompt": "Zadejte URL",
"sending": "Odesílání...",
+5
View File
@@ -809,6 +809,11 @@
"send_anyway": "Send alligevel",
"back": "Tilbage til redigering"
},
"placeholder_words": {
"before": ["din", "dit", "dine", "indsæt", "tilføj", "skriv"],
"after": [],
"unfinished": []
},
"link_url_prompt": "Indtast URL'en",
"schedule_send": "Planlæg afsendelse",
"schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.",
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Trotzdem senden",
"back": "Zurück zur Bearbeitung"
},
"placeholder_words": {
"before": ["ihr", "ihre", "ihren", "dein", "deine", "deinen"],
"after": ["einfügen", "eintragen", "ergänzen"],
"unfinished": []
},
"add_link": "Link hinzufügen",
"link_url_prompt": "URL eingeben",
"sending": "Wird gesendet...",
+5
View File
@@ -812,6 +812,11 @@
"send_anyway": "Send anyway",
"back": "Back to editing"
},
"placeholder_words": {
"before": ["your", "insert", "enter", "add"],
"after": [],
"unfinished": ["TODO", "TBD", "TBA", "TBC", "FIXME", "XXX"]
},
"schedule_send": "Schedule send",
"schedule_send_description": "Choose when the server should release this message.",
"schedule_preset": {
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Enviar de todos modos",
"back": "Volver a editar"
},
"placeholder_words": {
"before": ["tu", "tus", "su", "sus", "insertar", "inserte", "inserta", "añadir", "añade", "agregar", "agrega", "escribe", "escriba", "indica", "indique"],
"after": [],
"unfinished": []
},
"add_link": "Añadir enlace",
"link_url_prompt": "Introduce la URL",
"sending": "Enviando...",
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "Saada ikkagi",
"back": "Tagasi muutmise juurde"
},
"placeholder_words": {
"before": ["teie", "sinu", "sisesta", "sisestage", "lisa", "lisage"],
"after": [],
"unfinished": []
},
"schedule_send": "Ajasta saatmine",
"schedule_send_description": "Vali, millal server peaks selle sõnumi välja saatma.",
"schedule_preset": {
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "ارسال به هر حال",
"back": "بازگشت به ویرایش"
},
"placeholder_words": {
"before": [],
"after": ["شما", "وارد کنید", "اضافه کنید"],
"unfinished": []
},
"schedule_send": "زمان‌بندی ارسال",
"schedule_send_description": "زمان انتشار پیام توسط سرور را انتخاب کنید.",
"schedule_preset": {
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Envoyer quand même",
"back": "Retour à l'édition"
},
"placeholder_words": {
"before": ["votre", "vos", "ton", "ta", "tes", "insérer", "insérez", "ajouter", "ajoutez", "saisir", "saisissez", "indiquer", "indiquez"],
"after": [],
"unfinished": []
},
"add_link": "Ajouter un lien",
"link_url_prompt": "Saisissez l'URL",
"sending": "Envoi...",
+5
View File
@@ -775,6 +775,11 @@
"send_anyway": "שלח בכל זאת",
"back": "חזור לעריכה"
},
"placeholder_words": {
"before": ["הכנס", "הכניסו", "הזן", "הוסף"],
"after": ["שלך", "שלכם"],
"unfinished": []
},
"schedule_send": "תזמן שליחה",
"schedule_send_description": "בחר מתי השרת צריך לשחרר הודעה זו.",
"schedule_preset": {
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "Küldés így is",
"back": "Vissza a szerkesztéshez"
},
"placeholder_words": {
"before": ["az ön", "a te", "írja be", "illessze be", "adja meg"],
"after": [],
"unfinished": []
},
"schedule_send": "Késleltetett küldés",
"schedule_send_description": "Válaszd ki, mikor küldje el a szerver az üzenetet.",
"schedule_preset": {
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Invia comunque",
"back": "Torna alla modifica"
},
"placeholder_words": {
"before": ["il tuo", "la tua", "i tuoi", "le tue", "il suo", "la sua", "inserisci", "inserire", "aggiungi", "aggiungere", "indica"],
"after": [],
"unfinished": []
},
"add_link": "Aggiungi link",
"link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso...",
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "そのまま送信",
"back": "編集に戻る"
},
"placeholder_words": {
"before": ["あなたの-", "貴社の-", "御社の-"],
"after": ["-を入力", "-を記入", "-を挿入", "-を追加", "-を入れる"],
"unfinished": []
},
"add_link": "リンクを追加",
"link_url_prompt": "URLを入力してください",
"sending": "送信中...",
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "그래도 보내기",
"back": "편집으로 돌아가기"
},
"placeholder_words": {
"before": ["귀하의", "당신의", "고객님의"],
"after": ["입력", "삽입", "추가"],
"unfinished": []
},
"add_link": "링크 추가",
"link_url_prompt": "URL을 입력하세요",
"sending": "전송 중...",
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Sūtīt tik un tā",
"back": "Atpakaļ pie rediģēšanas"
},
"placeholder_words": {
"before": ["jūsu", "tavs", "tava", "ievadiet", "ierakstiet", "ievietojiet", "pievienojiet", "norādiet"],
"after": [],
"unfinished": []
},
"add_link": "Pievienot saiti",
"link_url_prompt": "Ievadiet URL",
"sending": "Sūta...",
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "Ямар ч байсан илгээнэ үү",
"back": "Засварлах руу буцах"
},
"placeholder_words": {
"before": ["таны", "чиний"],
"after": ["оруулна уу"],
"unfinished": []
},
"schedule_send": "Хуваарь илгээх",
"schedule_send_description": "Сервер энэ мессежийг хэзээ гаргахыг сонгоно уу.",
"schedule_preset": {
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "Send likevel",
"back": "Tilbake til redigering"
},
"placeholder_words": {
"before": ["din", "ditt", "dine", "sett inn", "legg til", "skriv"],
"after": [],
"unfinished": []
},
"schedule_send": "Planlegg sending",
"schedule_send_description": "Velg når serveren skal sende denne meldingen.",
"schedule_preset": {
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Toch verzenden",
"back": "Terug naar bewerken"
},
"placeholder_words": {
"before": ["je", "jouw", "uw", "voeg", "vul"],
"after": ["invoegen", "invullen", "toevoegen"],
"unfinished": []
},
"add_link": "Link toevoegen",
"link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden...",
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Wyślij mimo to",
"back": "Wróć do edycji"
},
"placeholder_words": {
"before": ["twoje", "twój", "twoja", "pana", "pani", "wstaw", "wpisz", "dodaj", "podaj"],
"after": [],
"unfinished": []
},
"add_link": "Dodaj link",
"link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie...",
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Enviar mesmo assim",
"back": "Voltar à edição"
},
"placeholder_words": {
"before": ["seu", "sua", "seus", "suas", "teu", "tua", "inserir", "insira", "adicionar", "adicione", "digite", "informe"],
"after": [],
"unfinished": []
},
"add_link": "Adicionar link",
"link_url_prompt": "Insira a URL",
"sending": "Enviando...",
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "Trimite oricum",
"back": "Înapoi la editare"
},
"placeholder_words": {
"before": ["introduceți", "inserați", "adăugați", "completați"],
"after": ["tău", "ta", "dvs.", "dumneavoastră"],
"unfinished": []
},
"schedule_send": "Programează trimiterea",
"schedule_send_description": "Alegeți momentul în care serverul ar trebui să transmită acest mesaj.",
"schedule_preset": {
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Отправить всё равно",
"back": "Вернуться к редактированию"
},
"placeholder_words": {
"before": ["ваше", "ваш", "ваша", "ваши", "твоё", "твое", "твой", "твоя", "вставьте", "укажите", "введите", "добавьте"],
"after": [],
"unfinished": []
},
"add_link": "Добавить ссылку",
"link_url_prompt": "Введите URL",
"sending": "Отправка...",
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "Odoslať aj tak",
"back": "Späť na úpravu"
},
"placeholder_words": {
"before": ["vaše", "váš", "vaša", "tvoje", "vložte", "doplňte", "zadajte", "uveďte"],
"after": [],
"unfinished": []
},
"schedule_send": "Naplánovať odoslanie",
"schedule_send_description": "Vyberte, kedy má server túto správu odoslať.",
"schedule_preset": {
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "Yine de gönder",
"back": "Düzenlemeye dön"
},
"placeholder_words": {
"before": ["şirketinizin", "buraya"],
"after": ["ekleyin", "girin", "yazın", "-ınız", "-iniz", "-unuz", "-ünüz"],
"unfinished": []
},
"schedule_send": "Göndermeyi zamanla",
"schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.",
"schedule_preset": {
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "Все одно надішліть",
"back": "Назад до редагування"
},
"placeholder_words": {
"before": ["ваше", "ваш", "ваша", "ваші", "твоє", "твій", "твоя", "вставте", "вкажіть", "введіть", "додайте"],
"after": [],
"unfinished": []
},
"add_link": "Додати посилання",
"link_url_prompt": "Введіть URL",
"sending": "Надсилання...",
+5
View File
@@ -810,6 +810,11 @@
"send_anyway": "仍然傳送",
"back": "繼續編輯"
},
"placeholder_words": {
"before": ["您的-", "你的-", "貴公司-", "插入-", "輸入-", "填寫-", "新增-", "在此-"],
"after": [],
"unfinished": []
},
"schedule_send": "排程寄送",
"schedule_send_description": "選擇伺服器要在何時送出這封郵件。",
"schedule_preset": {
+5
View File
@@ -807,6 +807,11 @@
"send_anyway": "仍然发送",
"back": "返回编辑"
},
"placeholder_words": {
"before": ["您的-", "你的-", "贵公司-", "插入-", "输入-", "填写-", "添加-", "在此-"],
"after": [],
"unfinished": []
},
"add_link": "添加链接",
"link_url_prompt": "输入 URL",
"sending": "发送中...",