mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: let each translation list its own placeholder fill-in words
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -810,6 +810,11 @@
|
||||
"send_anyway": "الإرسال على أي حال",
|
||||
"back": "العودة إلى التعديل"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": ["أدخل", "ادخل", "أضف", "اكتب"],
|
||||
"after": [],
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "جدولة الإرسال",
|
||||
"schedule_send_description": "اختر متى يجب على الخادم إرسال هذه الرسالة.",
|
||||
"schedule_preset": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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í...",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -810,6 +810,11 @@
|
||||
"send_anyway": "ارسال به هر حال",
|
||||
"back": "بازگشت به ویرایش"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": [],
|
||||
"after": ["شما", "وارد کنید", "اضافه کنید"],
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "زمانبندی ارسال",
|
||||
"schedule_send_description": "زمان انتشار پیام توسط سرور را انتخاب کنید.",
|
||||
"schedule_preset": {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -775,6 +775,11 @@
|
||||
"send_anyway": "שלח בכל זאת",
|
||||
"back": "חזור לעריכה"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": ["הכנס", "הכניסו", "הזן", "הוסף"],
|
||||
"after": ["שלך", "שלכם"],
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "תזמן שליחה",
|
||||
"schedule_send_description": "בחר מתי השרת צריך לשחרר הודעה זו.",
|
||||
"schedule_preset": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -807,6 +807,11 @@
|
||||
"send_anyway": "そのまま送信",
|
||||
"back": "編集に戻る"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": ["あなたの-", "貴社の-", "御社の-"],
|
||||
"after": ["-を入力", "-を記入", "-を挿入", "-を追加", "-を入れる"],
|
||||
"unfinished": []
|
||||
},
|
||||
"add_link": "リンクを追加",
|
||||
"link_url_prompt": "URLを入力してください",
|
||||
"sending": "送信中...",
|
||||
|
||||
@@ -807,6 +807,11 @@
|
||||
"send_anyway": "그래도 보내기",
|
||||
"back": "편집으로 돌아가기"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": ["귀하의", "당신의", "고객님의"],
|
||||
"after": ["입력", "삽입", "추가"],
|
||||
"unfinished": []
|
||||
},
|
||||
"add_link": "링크 추가",
|
||||
"link_url_prompt": "URL을 입력하세요",
|
||||
"sending": "전송 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -810,6 +810,11 @@
|
||||
"send_anyway": "Ямар ч байсан илгээнэ үү",
|
||||
"back": "Засварлах руу буцах"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": ["таны", "чиний"],
|
||||
"after": ["оруулна уу"],
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "Хуваарь илгээх",
|
||||
"schedule_send_description": "Сервер энэ мессежийг хэзээ гаргахыг сонгоно уу.",
|
||||
"schedule_preset": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -807,6 +807,11 @@
|
||||
"send_anyway": "Отправить всё равно",
|
||||
"back": "Вернуться к редактированию"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": ["ваше", "ваш", "ваша", "ваши", "твоё", "твое", "твой", "твоя", "вставьте", "укажите", "введите", "добавьте"],
|
||||
"after": [],
|
||||
"unfinished": []
|
||||
},
|
||||
"add_link": "Добавить ссылку",
|
||||
"link_url_prompt": "Введите URL",
|
||||
"sending": "Отправка...",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -807,6 +807,11 @@
|
||||
"send_anyway": "Все одно надішліть",
|
||||
"back": "Назад до редагування"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": ["ваше", "ваш", "ваша", "ваші", "твоє", "твій", "твоя", "вставте", "вкажіть", "введіть", "додайте"],
|
||||
"after": [],
|
||||
"unfinished": []
|
||||
},
|
||||
"add_link": "Додати посилання",
|
||||
"link_url_prompt": "Введіть URL",
|
||||
"sending": "Надсилання...",
|
||||
|
||||
@@ -810,6 +810,11 @@
|
||||
"send_anyway": "仍然傳送",
|
||||
"back": "繼續編輯"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": ["您的-", "你的-", "貴公司-", "插入-", "輸入-", "填寫-", "新增-", "在此-"],
|
||||
"after": [],
|
||||
"unfinished": []
|
||||
},
|
||||
"schedule_send": "排程寄送",
|
||||
"schedule_send_description": "選擇伺服器要在何時送出這封郵件。",
|
||||
"schedule_preset": {
|
||||
|
||||
@@ -807,6 +807,11 @@
|
||||
"send_anyway": "仍然发送",
|
||||
"back": "返回编辑"
|
||||
},
|
||||
"placeholder_words": {
|
||||
"before": ["您的-", "你的-", "贵公司-", "插入-", "输入-", "填写-", "添加-", "在此-"],
|
||||
"after": [],
|
||||
"unfinished": []
|
||||
},
|
||||
"add_link": "添加链接",
|
||||
"link_url_prompt": "输入 URL",
|
||||
"sending": "发送中...",
|
||||
|
||||
Reference in New Issue
Block a user