Add lazy widget language localization

This commit is contained in:
rajchales-monito
2026-07-30 08:13:57 +02:00
parent 05636ab77e
commit 80540cf188
5 changed files with 439 additions and 38 deletions
+220 -8
View File
@@ -23,6 +23,12 @@ const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gi
const MAX_ATTACHMENT_BYTES = 3_000_000;
const ACTIVE_VISITOR_SECONDS = 90;
const PRESENT_VISITOR_SECONDS = 300;
const WIDGET_COPY_FIELDS = ["title", "intro", "offlineIntro", "placeholder", "sendLabel", "dropzoneLabel", "onlineLabel", "offlineLabel", "newReplyLabel", "typingLabel", "infoTitle", "infoText", "closeLabel", "operatorReplyLabel"];
const EUROPEAN_WIDGET_LANGUAGES = new Set([
"sq", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el",
"hu", "is", "ga", "it", "lv", "lt", "mk", "mt", "no", "pl", "pt", "ro", "ru", "sr", "sk",
"sl", "es", "sv", "tr", "uk"
]);
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
@@ -286,12 +292,23 @@ function seedDefaults() {
}
function defaultSettings() {
const widgetCopy = {
cs: defaultWidgetCopy("cs"),
en: defaultWidgetCopy("en")
};
return {
title: { cs: "Potrebujete poradit?", en: "Need help?" },
intro: { cs: "Napiste nam. Odpovime co nejdrive.", en: "Message us and we will reply as soon as possible." },
offlineIntro: { cs: "Ted nejsme online, ale zpravu nam muzete nechat.", en: "We are offline, but you can leave us a message." },
placeholder: { cs: "Napiste zpravu...", en: "Write a message..." },
sendLabel: { cs: "Odeslat", en: "Send" },
widgetLanguage: {
mode: "auto",
defaultLanguage: "cs",
fixedLanguage: "cs",
fallbackLanguage: "cs"
},
widgetCopy,
title: { cs: widgetCopy.cs.title, en: widgetCopy.en.title },
intro: { cs: widgetCopy.cs.intro, en: widgetCopy.en.intro },
offlineIntro: { cs: widgetCopy.cs.offlineIntro, en: widgetCopy.en.offlineIntro },
placeholder: { cs: widgetCopy.cs.placeholder, en: widgetCopy.en.placeholder },
sendLabel: { cs: widgetCopy.cs.sendLabel, en: widgetCopy.en.sendLabel },
detailsLabel: { cs: "Kontaktni udaje", en: "Contact details" },
colors: {
brand: "#0f8f6f",
@@ -323,6 +340,43 @@ function defaultSettings() {
};
}
function defaultWidgetCopy(language) {
if (language === "en") {
return {
title: "Need help?",
intro: "Message us and we will reply as soon as possible.",
offlineIntro: "We are offline, but you can leave us a message.",
placeholder: "Write a message...",
sendLabel: "Send",
dropzoneLabel: "Add a photo or drag it here",
onlineLabel: "We are online",
offlineLabel: "Offline",
newReplyLabel: "New reply",
typingLabel: "Operator is typing...",
infoTitle: "Privacy information",
infoText: "We process your message and technical session data only to answer your request.",
closeLabel: "Minimize chat",
operatorReplyLabel: "is replying"
};
}
return {
title: "Potrebujete poradit?",
intro: "Napiste nam. Odpovime co nejdrive.",
offlineIntro: "Ted nejsme online, ale zpravu nam muzete nechat.",
placeholder: "Napiste zpravu...",
sendLabel: "Odeslat",
dropzoneLabel: "Pridat fotku nebo pretahnout",
onlineLabel: "Jsme online",
offlineLabel: "Offline",
newReplyLabel: "Nova odpoved",
typingLabel: "Operator pise...",
infoTitle: "GDPR informace",
infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.",
closeLabel: "Zmensit chat",
operatorReplyLabel: "odpovida"
};
}
function configuredUsers() {
const users = [];
for (let i = 1; i < 50; i += 1) {
@@ -634,11 +688,13 @@ function deleteConversation(res, url) {
return json(res, 200, { ok: true });
}
function widgetConfig(res, url) {
async function widgetConfig(res, url) {
const siteKey = url.searchParams.get("siteKey") || process.env.DEFAULT_SITE_KEY || "9b-plus";
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(siteKey);
if (!site) return json(res, 404, { error: "site_not_found" });
return json(res, 200, { site: formatSite(site), publicBaseUrl: PUBLIC_BASE_URL });
const language = widgetLanguageForRequest(site, url);
const copy = await widgetCopyForLanguage(site, language);
return json(res, 200, { site: formatSite(getSiteById(site.id)), publicBaseUrl: PUBLIC_BASE_URL, language: copy.language, copy: copy.copy });
}
async function visitorPing(req, res) {
@@ -1017,6 +1073,8 @@ function outputTextFromResponse(data) {
function mergeSiteSettings(currentSettings, incomingSettings) {
const settings = { ...currentSettings, ...incomingSettings };
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage || currentSettings.widgetLanguage || {});
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || currentSettings.widgetCopy || {}, settings);
const currentTranslations = currentSettings.translations || {};
const incomingTranslations = incomingSettings.translations || {};
settings.translations = {
@@ -1035,6 +1093,58 @@ function mergeSiteSettings(currentSettings, incomingSettings) {
return settings;
}
function normalizeWidgetLanguageSettings(value) {
const defaults = defaultSettings().widgetLanguage;
const settings = { ...defaults, ...(value || {}) };
const mode = settings.mode === "fixed" ? "fixed" : "auto";
return {
mode,
defaultLanguage: allowedWidgetLanguage(settings.defaultLanguage) || defaults.defaultLanguage,
fixedLanguage: allowedWidgetLanguage(settings.fixedLanguage) || defaults.fixedLanguage,
fallbackLanguage: allowedWidgetLanguage(settings.fallbackLanguage) || allowedWidgetLanguage(settings.defaultLanguage) || defaults.fallbackLanguage
};
}
function normalizeWidgetCopy(value, settings = {}) {
const copy = { ...(value || {}) };
const legacyLanguages = new Set([
...Object.keys(settings.title || {}),
...Object.keys(settings.intro || {}),
...Object.keys(settings.offlineIntro || {}),
...Object.keys(settings.placeholder || {}),
...Object.keys(settings.sendLabel || {})
]);
for (const language of legacyLanguages) {
const lang = allowedWidgetLanguage(language);
if (!lang) continue;
copy[lang] = {
...(copy[lang] || {}),
title: settings.title?.[lang],
intro: settings.intro?.[lang],
offlineIntro: settings.offlineIntro?.[lang],
placeholder: settings.placeholder?.[lang],
sendLabel: settings.sendLabel?.[lang]
};
}
copy.cs = normalizeWidgetCopyEntry(copy.cs || defaultWidgetCopy("cs"), "cs");
copy.en = normalizeWidgetCopyEntry(copy.en || defaultWidgetCopy("en"), "en");
for (const language of Object.keys(copy)) {
const lang = allowedWidgetLanguage(language);
if (!lang) delete copy[language];
else copy[lang] = normalizeWidgetCopyEntry(copy[language], lang);
}
return copy;
}
function normalizeWidgetCopyEntry(value, language) {
const defaults = defaultWidgetCopy(language === "en" ? "en" : "cs");
const copy = {};
for (const field of WIDGET_COPY_FIELDS) {
copy[field] = String(value?.[field] || defaults[field] || "").trim();
}
return copy;
}
function getTranslationSettings() {
const site = getDefaultSite();
const settings = parseJson(site.settings_json, defaultSettings());
@@ -1047,6 +1157,103 @@ function getTranslationSettings() {
};
}
function widgetLanguageForRequest(site, url) {
const settings = parseJson(site.settings_json, defaultSettings());
const languageSettings = normalizeWidgetLanguageSettings(settings.widgetLanguage);
if (languageSettings.mode === "fixed") return languageSettings.fixedLanguage;
return allowedWidgetLanguage(url.searchParams.get("lang"))
|| allowedWidgetLanguage(url.searchParams.get("pageLang"))
|| languageFromUrl(url.searchParams.get("currentUrl"))
|| allowedWidgetLanguage(url.searchParams.get("browserLang"))
|| languageSettings.fallbackLanguage;
}
async function widgetCopyForLanguage(site, requestedLanguage) {
const settings = parseJson(site.settings_json, defaultSettings());
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage);
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || {}, settings);
const language = allowedWidgetLanguage(requestedLanguage) || settings.widgetLanguage.fallbackLanguage;
if (settings.widgetCopy[language]) return { language, copy: settings.widgetCopy[language] };
const fallbackLanguage = settings.widgetCopy[settings.widgetLanguage.fallbackLanguage]
? settings.widgetLanguage.fallbackLanguage
: settings.widgetLanguage.defaultLanguage;
const fallbackCopy = settings.widgetCopy[fallbackLanguage] || defaultWidgetCopy("cs");
const translated = await translateWidgetCopyOnce(fallbackCopy, fallbackLanguage, language);
if (!translated) return { language: fallbackLanguage, copy: fallbackCopy };
settings.widgetCopy[language] = translated;
syncLegacyWidgetCopy(settings);
db.prepare("UPDATE sites SET settings_json = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(JSON.stringify(settings), site.id);
return { language, copy: translated };
}
async function translateWidgetCopyOnce(sourceCopy, sourceLanguage, targetLanguage) {
const settings = getTranslationSettings();
if (!settings.enabled || !settings.openaiApiKey || !targetLanguage || sourceLanguage === targetLanguage) return null;
try {
const response = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: {
"Authorization": `Bearer ${settings.openaiApiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: settings.model,
input: [
{
role: "system",
content: "Translate UI copy for a live chat widget. Return only compact JSON with the same keys. Preserve brand names and meaning. Keep labels short."
},
{
role: "user",
content: JSON.stringify({ sourceLanguage, targetLanguage, copy: sourceCopy })
}
]
})
});
if (!response.ok) {
const openAiError = await openAiErrorFromResponse(response);
throw new Error(openAiError.message);
}
const data = await response.json();
return normalizeWidgetCopyEntry(parseJsonBlock(outputTextFromResponse(data)), targetLanguage);
} catch (error) {
console.error("widget_copy_translation_failed", targetLanguage, error.message);
return null;
}
}
function parseJsonBlock(value) {
const text = String(value || "").trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
return JSON.parse(text);
}
function syncLegacyWidgetCopy(settings) {
for (const language of Object.keys(settings.widgetCopy || {})) {
const copy = settings.widgetCopy[language];
settings.title = { ...(settings.title || {}), [language]: copy.title };
settings.intro = { ...(settings.intro || {}), [language]: copy.intro };
settings.offlineIntro = { ...(settings.offlineIntro || {}), [language]: copy.offlineIntro };
settings.placeholder = { ...(settings.placeholder || {}), [language]: copy.placeholder };
settings.sendLabel = { ...(settings.sendLabel || {}), [language]: copy.sendLabel };
}
}
function languageFromUrl(value) {
try {
return allowedWidgetLanguage(new URL(value).pathname.split("/").filter(Boolean)[0]);
} catch {
return "";
}
}
function allowedWidgetLanguage(value) {
const language = normalizeLanguage(value);
return EUROPEAN_WIDGET_LANGUAGES.has(language) ? language : "";
}
function normalizeOpenAiApiKey(value) {
const text = String(value || "").trim();
if (!text || isMaskedApiKey(text)) return "";
@@ -1283,6 +1490,8 @@ function formatSite(site) {
const settings = parseJson(site.settings_json, defaultSettings());
settings.notifications = { ...defaultSettings().notifications, ...(settings.notifications || {}) };
settings.launcherEffects = { ...defaultSettings().launcherEffects, ...(settings.launcherEffects || {}) };
settings.widgetLanguage = normalizeWidgetLanguageSettings(settings.widgetLanguage);
settings.widgetCopy = normalizeWidgetCopy(settings.widgetCopy || {}, settings);
settings.translations = { ...defaultSettings().translations, ...(settings.translations || {}) };
const storedOpenAiKey = settings.translations.openaiApiKey || OPENAI_API_KEY;
settings.translations.hasOpenaiApiKey = Boolean(storedOpenAiKey);
@@ -1302,7 +1511,6 @@ function snippetFor(site) {
return `<script>
window.MaalFlows = {
siteKey: "${site.site_key}",
lang: "cs",
apiBase: "${PUBLIC_BASE_URL}"
};
</script>
@@ -1313,6 +1521,10 @@ function getDefaultSite() {
return db.prepare("SELECT * FROM sites ORDER BY id LIMIT 1").get();
}
function getSiteById(id) {
return db.prepare("SELECT * FROM sites WHERE id = ?").get(id);
}
function findConversation(publicId) {
return db.prepare(`
SELECT c.*, s.site_key, vp.display_name AS visitor_display_name,