Minimal WME UR helper rebuilt around direct UR pane integration for templates, translation, and AI replies.
// ==UserScript==
// @name MapReply Copilot
// @namespace https://example.local/mapreply-copilot
// @version 1.0.0
// @description Minimal WME UR helper rebuilt around direct UR pane integration for templates, translation, and AI replies.
// @author Dutrus
// @match https://www.waze.com/*/editor*
// @match https://www.waze.com/editor*
// @match https://beta.waze.com/*/editor*
// @match https://beta.waze.com/editor*
// @exclude https://www.waze.com/*/user/editor/*
// @exclude https://www.waze.com/discuss/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect translate.googleapis.com
// @connect api.openai.com
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const SCRIPT_ID = "mapreply-copilot";
const ROOT_ID = `${SCRIPT_ID}-root`;
const STYLE_ID = `${SCRIPT_ID}-styles`;
const DEFAULT_SETTINGS = {
selectedTemplate: "clarify",
editorLanguage: "",
openAiApiKey: "",
aiModel: "gpt-4.1-mini"
};
const TEMPLATE_LIBRARY = {
clarify: {
label: "Clarify",
text: "Thanks for your report. Could you please share a little more detail so we can confirm the issue and update the map correctly?"
},
review: {
label: "Review",
text: "Thanks for reporting this. We are reviewing the reported issue and will update the map if needed."
},
resolved: {
label: "Resolved",
text: "Thanks for your report. The issue appears to be corrected now. Please let us know if you still experience the same problem."
},
guidance: {
label: "Guidance",
text: "Thanks for the report. A route example, nearby landmark, or exact direction of travel would help us resolve this correctly."
}
};
const FAST_UR_TRANSLATE_LABELS = {
en: "Translate",
"pt-BR": "Traduzir",
"pt-PT": "Traduzir",
es: "Traducir",
"es-419": "Traducir",
fr: "Traduire",
de: "Ubersetzen",
it: "Tradurre",
fi: "Kaantaa",
hu: "Forditas",
no: "Oversett",
ro: "Traduce",
ru: "Perevesti",
bg: "Prevedi",
id: "Terjemahkan",
ja: "Translate",
ko: "Translate",
zh: "Translate",
"zh-TW": "Translate",
he: "Translate",
nl: "Vertalen",
pl: "Tlumacz",
ar: "Translate",
tr: "Cevir",
th: "Translate",
uk: "Pereklasty",
cs: "Prelozit",
el: "Translate",
sv: "Oversatt",
vi: "Dich",
da: "Oversaet",
hr: "Prevedi",
sk: "Prelozit",
sl: "Prevedi"
};
const SELECTORS = {
activePanel: [
".overlay-container wz-card[class*='panel'].problem-edit",
".overlay-container .problem-edit",
"wz-card[class*='panel'].problem-edit",
".problem-edit"
],
issueTitle: [
".sub-title",
'[data-testid="problem-title"]',
"h1",
"h2"
],
description: [
".body .problem-data .description .content",
'[data-testid="report-entry-description"]',
".description .content"
],
comments: [
".body .conversation .comment .comment-text",
".body .conversation .comment p",
"wz-list-item.comment .subtitle",
".comment-text",
".comment p"
],
replyForm: [
".body .conversation .new-comment-form",
".new-comment-form"
],
replyBox: [
".body .conversation .new-comment-text textarea",
"textarea[id^='wz-textarea-']",
"textarea"
],
discussionHeader: [
".body .conversation .title",
".conversation.section .title",
"[class*='conversation'] [class*='title']"
]
};
const STATE = {
settings: null,
observer: null,
timer: null,
root: null,
detectedLanguage: "",
translating: false
};
boot().catch((error) => {
console.error("MapReply Copilot boot failed:", error);
});
async function boot() {
STATE.settings = await loadSettings();
injectStyles();
ensureMounted();
observeApp();
}
async function loadSettings() {
const loaded = {};
for (const key of Object.keys(DEFAULT_SETTINGS)) {
loaded[key] = await GM_getValue(key, DEFAULT_SETTINGS[key]);
}
if (!loaded.editorLanguage) {
loaded.editorLanguage = getUiLanguage();
}
return loaded;
}
async function saveSettings(patch) {
STATE.settings = { ...STATE.settings, ...patch };
for (const [key, value] of Object.entries(patch)) {
await GM_setValue(key, value);
}
}
function getUiLanguage() {
const locale =
window.I18n?.currentLocale?.() ||
window.I18n?.locale ||
navigator.language ||
"en";
return String(locale).split("-")[0].toLowerCase();
}
function getTranslateLabel() {
const locale =
window.I18n?.currentLocale?.() ||
window.I18n?.locale ||
navigator.language ||
"en";
return FAST_UR_TRANSLATE_LABELS[locale]
|| FAST_UR_TRANSLATE_LABELS[String(locale).split("-")[0]]
|| FAST_UR_TRANSLATE_LABELS.en;
}
function observeApp() {
if (STATE.observer) {
STATE.observer.disconnect();
}
STATE.observer = new MutationObserver((mutations) => {
if (mutations.every((mutation) => isInsideMapReply(mutation.target))) {
return;
}
scheduleEnsure();
});
STATE.observer.observe(document.body, {
childList: true,
subtree: true
});
}
function scheduleEnsure() {
window.clearTimeout(STATE.timer);
STATE.timer = window.setTimeout(() => {
ensureMounted();
}, 250);
}
function isInsideMapReply(node) {
return node instanceof HTMLElement && Boolean(node.closest(`#${ROOT_ID}`));
}
function getActivePanel() {
for (const selector of SELECTORS.activePanel) {
const node = document.querySelector(selector);
if (node instanceof HTMLElement) {
return node;
}
}
return null;
}
function findReplyForm() {
const panel = getActivePanel() || document;
for (const selector of SELECTORS.replyForm) {
const node = panel.querySelector(selector);
if (node instanceof HTMLElement) {
return node;
}
}
return null;
}
function findReplyBox() {
const panel = getActivePanel() || document;
for (const selector of SELECTORS.replyBox) {
const node = panel.querySelector(selector);
if (node instanceof HTMLTextAreaElement) {
return node;
}
}
return null;
}
function findDiscussionHeader() {
const panel = getActivePanel() || document;
for (const selector of SELECTORS.discussionHeader) {
const nodes = [...panel.querySelectorAll(selector)];
const match = nodes.find((node) => {
const text = (node.textContent || "").trim().toLowerCase();
return text.includes("gesprek")
|| text.includes("conversation")
|| text.includes("comment")
|| text.includes("react");
});
if (match instanceof HTMLElement) {
return match;
}
}
return null;
}
function ensureMounted() {
const replyForm = findReplyForm();
if (!replyForm) {
if (STATE.root?.isConnected) {
STATE.root.remove();
}
STATE.root = null;
return;
}
const existing = replyForm.querySelector(`#${ROOT_ID}`);
if (existing instanceof HTMLElement) {
STATE.root = existing;
syncUi();
ensureHeaderTranslateButton();
return;
}
const root = document.createElement("div");
root.id = ROOT_ID;
root.className = "mrc-root";
root.innerHTML = renderRootMarkup();
const replyBox = findReplyBox();
const boxWrap = replyBox?.closest(".new-comment-text, [class*='new-comment-text'], .wz-textarea");
if (boxWrap instanceof HTMLElement && boxWrap.parentElement === replyForm) {
boxWrap.insertAdjacentElement("beforebegin", root);
} else {
replyForm.insertAdjacentElement("beforeend", root);
}
root.addEventListener("click", handleRootClick);
root.addEventListener("change", handleRootChange);
STATE.root = root;
syncUi();
ensureHeaderTranslateButton();
}
function renderRootMarkup() {
return `
<div class="mrc-row mrc-row-top">
<span class="mrc-kicker">MapReply</span>
<span class="mrc-status" data-role="status">Klaar.</span>
</div>
<div class="mrc-row mrc-row-tools">
<label class="mrc-template">
<span>T</span>
<select data-setting="selectedTemplate">
${Object.entries(TEMPLATE_LIBRARY)
.map(([key, value]) => `<option value="${escapeHtml(key)}">${escapeHtml(value.label)}</option>`)
.join("")}
</select>
</label>
<button type="button" data-action="template">Invullen</button>
<button type="button" data-action="translate-thread">${escapeHtml(getTranslateLabel())}</button>
<button type="button" data-action="translate-draft">Vertaal reply</button>
</div>
<div class="mrc-row mrc-row-ai">
<span class="mrc-ai-label">AI</span>
<button type="button" data-action="ai-clarify">Vraag door</button>
<button type="button" data-action="ai-answer">Antwoord</button>
<button type="button" data-action="ai-close">Afsluiten</button>
</div>
<div class="mrc-results" data-role="results"></div>
`;
}
function syncUi() {
if (!STATE.root) {
return;
}
const select = STATE.root.querySelector('[data-setting="selectedTemplate"]');
if (select) {
select.value = STATE.settings.selectedTemplate;
}
}
function ensureHeaderTranslateButton() {
const header = findDiscussionHeader();
if (!header) {
return;
}
const existing = header.querySelector(".mrc-header-translate");
if (existing instanceof HTMLButtonElement) {
existing.textContent = getTranslateLabel();
return;
}
const button = document.createElement("button");
button.type = "button";
button.className = "mrc-header-translate";
button.textContent = getTranslateLabel();
button.addEventListener("click", async () => {
try {
await translateIncomingThread();
} catch (error) {
setStatus(error.message || String(error), true);
}
});
header.appendChild(button);
}
async function handleRootClick(event) {
const button = event.target.closest("[data-action]");
if (!button) {
return;
}
try {
switch (button.getAttribute("data-action")) {
case "template":
applyTemplate();
break;
case "translate-thread":
await translateIncomingThread();
break;
case "translate-draft":
await translateDraftToReporter();
break;
case "ai-clarify":
await generateAiReply("clarify");
break;
case "ai-answer":
await generateAiReply("answer");
break;
case "ai-close":
await generateAiReply("close");
break;
default:
break;
}
} catch (error) {
setStatus(error.message || String(error), true);
}
}
async function handleRootChange(event) {
const select = event.target.closest('[data-setting="selectedTemplate"]');
if (!select) {
return;
}
await saveSettings({ selectedTemplate: select.value });
syncUi();
}
function collectContext() {
const panel = getActivePanel() || document;
const issueTitle = textFromSelectors(SELECTORS.issueTitle, panel);
const descriptionNode = firstNodeFromSelectors(SELECTORS.description, panel);
const description = normalizeText(descriptionNode?.textContent || "");
const commentNodes = nodesFromSelectors(SELECTORS.comments, panel).slice(0, 12);
const comments = commentNodes.map((node) => normalizeText(node.textContent || "")).filter(Boolean);
const reporterMessage = [description, ...comments].filter(Boolean).join("\n\n").trim();
return {
panel,
issueTitle,
description,
descriptionNode,
commentNodes,
comments,
reporterMessage
};
}
function textFromSelectors(selectors, scope) {
for (const selector of selectors) {
const node = scope.querySelector(selector);
const text = normalizeText(node?.textContent || "");
if (text) {
return text;
}
}
return "";
}
function firstNodeFromSelectors(selectors, scope) {
for (const selector of selectors) {
const node = scope.querySelector(selector);
if (node instanceof HTMLElement) {
return node;
}
}
return null;
}
function nodesFromSelectors(selectors, scope) {
for (const selector of selectors) {
const nodes = [...scope.querySelectorAll(selector)].filter((node) => node instanceof HTMLElement);
if (nodes.length) {
return nodes;
}
}
return [];
}
function normalizeText(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
function applyTemplate() {
const replyBox = findReplyBox();
if (!replyBox) {
setStatus("Geen replyveld gevonden.", true);
return;
}
const template = TEMPLATE_LIBRARY[STATE.settings.selectedTemplate] || TEMPLATE_LIBRARY.clarify;
setReplyBoxValue(replyBox, template.text);
setStatus(`Template geladen: ${template.label}.`);
renderResult(`<strong>Template</strong> ${escapeHtml(template.label)}`);
}
async function translateIncomingThread() {
if (STATE.translating) {
return;
}
STATE.translating = true;
try {
const context = collectContext();
if (!context.reporterMessage) {
throw new Error("Geen meldertekst gevonden in deze UR.");
}
clearInlineTranslations();
const detection = await detectLanguage(context.reporterMessage);
STATE.detectedLanguage = detection.language || "";
if (!detection.language || detection.language === "unknown") {
throw new Error("Taal van de melder kon niet worden herkend.");
}
if (normalizeLanguage(detection.language) === normalizeLanguage(STATE.settings.editorLanguage)) {
setStatus(`Meldertaal is al ${detection.language}.`);
renderResult(`<strong>Taal</strong> ${escapeHtml(detection.language)} | al gelijk aan editor`);
return;
}
if (context.descriptionNode) {
const translatedDescription = await translateText(context.description, STATE.settings.editorLanguage);
insertInlineTranslation(context.descriptionNode, translatedDescription.translatedText, detection.language);
}
for (const node of context.commentNodes) {
const original = normalizeText(node.textContent || "");
if (!original) {
continue;
}
const translated = await translateText(original, STATE.settings.editorLanguage);
insertInlineTranslation(node, translated.translatedText, detection.language);
}
setStatus(`Gesprek vertaald uit ${detection.language}.`);
renderResult(
`<strong>Taal</strong> ${escapeHtml(detection.language)} | <strong>Editor</strong> ${escapeHtml(STATE.settings.editorLanguage)} | <strong>Zekerheid</strong> ${Math.round((detection.confidence || 0) * 100)}%`
);
} finally {
STATE.translating = false;
}
}
async function translateDraftToReporter() {
const replyBox = findReplyBox();
if (!replyBox) {
throw new Error("Geen replyveld gevonden.");
}
const draft = replyBox.value.trim();
if (!draft) {
throw new Error("Schrijf eerst een reply.");
}
let targetLanguage = STATE.detectedLanguage;
if (!targetLanguage) {
const context = collectContext();
if (!context.reporterMessage) {
throw new Error("Geen meldertekst gevonden om de taal te bepalen.");
}
const detection = await detectLanguage(context.reporterMessage);
targetLanguage = detection.language;
STATE.detectedLanguage = targetLanguage;
}
if (!targetLanguage || targetLanguage === "unknown") {
throw new Error("Meldertaal kon niet worden bepaald.");
}
if (normalizeLanguage(targetLanguage) === normalizeLanguage(STATE.settings.editorLanguage)) {
setStatus("Reply hoeft niet vertaald te worden.");
return;
}
const translated = await translateText(draft, targetLanguage);
setReplyBoxValue(replyBox, translated.translatedText);
setStatus(`Reply vertaald naar ${targetLanguage}.`);
renderResult(`<strong>Reply vertaald</strong> ${escapeHtml(STATE.settings.editorLanguage)} -> ${escapeHtml(targetLanguage)}`);
}
async function generateAiReply(mode) {
const apiKey = await ensureOpenAiKey();
if (!apiKey) {
throw new Error("OpenAI API key ontbreekt.");
}
const context = collectContext();
if (!context.reporterMessage) {
throw new Error("Geen genoeg UR-context gevonden voor AI.");
}
if (!STATE.detectedLanguage) {
const detection = await detectLanguage(context.reporterMessage);
STATE.detectedLanguage = detection.language || "";
}
const prompt = {
mode,
issueTitle: context.issueTitle,
description: context.description,
comments: context.comments,
reporterLanguage: STATE.detectedLanguage || "unknown",
editorLanguage: STATE.settings.editorLanguage
};
const response = await requestJson({
method: "POST",
url: "https://api.openai.com/v1/responses",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`
},
data: JSON.stringify({
model: STATE.settings.aiModel,
input: [
{
role: "system",
content: [
{
type: "input_text",
text: "You are a Waze Map Editor helper. Produce exactly one concise, practical reply draft for a user report. Be polite, concrete, and avoid overpromising. Return JSON only with keys reply and note."
}
]
},
{
role: "user",
content: [
{
type: "input_text",
text: JSON.stringify(prompt)
}
]
}
]
})
});
const output = String(response.output_text || "").trim();
if (!output) {
throw new Error("AI gaf geen antwoord terug.");
}
const parsed = JSON.parse(output);
if (!parsed.reply) {
throw new Error("AI antwoord bevat geen reply.");
}
const replyBox = findReplyBox();
if (!replyBox) {
throw new Error("Geen replyveld gevonden.");
}
setReplyBoxValue(replyBox, parsed.reply);
setStatus("AI reply ingevuld.");
renderResult(`<strong>AI</strong> ${escapeHtml(parsed.note || mode)}`);
}
async function ensureOpenAiKey() {
if (STATE.settings.openAiApiKey) {
return STATE.settings.openAiApiKey;
}
const provided = window.prompt("Voer je OpenAI API key in voor MapReply AI:");
if (!provided) {
return "";
}
await saveSettings({ openAiApiKey: provided.trim() });
return STATE.settings.openAiApiKey;
}
async function detectLanguage(text) {
const data = await requestJson({
method: "GET",
url: buildGoogleTranslateUrl(text, "en", "auto")
});
return {
language: typeof data?.[2] === "string" ? data[2] : "unknown",
confidence: typeof data?.[6] === "number" ? data[6] : 0.75
};
}
async function translateText(text, targetLanguage) {
const data = await requestJson({
method: "GET",
url: buildGoogleTranslateUrl(text, targetLanguage, "auto")
});
const translatedText = Array.isArray(data?.[0])
? data[0]
.filter((part) => Array.isArray(part) && typeof part[0] === "string")
.map((part) => part[0])
.join(" ")
.trim()
: "";
return {
translatedText,
sourceLanguage: typeof data?.[2] === "string" ? data[2] : "unknown"
};
}
function buildGoogleTranslateUrl(text, targetLanguage, sourceLanguage) {
const url = new URL("https://translate.googleapis.com/translate_a/single");
url.searchParams.set("client", "gtx");
url.searchParams.set("sl", sourceLanguage);
url.searchParams.set("tl", targetLanguage);
url.searchParams.set("dt", "t");
url.searchParams.set("q", text);
return url.toString();
}
function requestJson({ method, url, headers = {}, data }) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers,
data,
onload: (response) => {
if (response.status < 200 || response.status >= 300) {
reject(new Error(`Request failed: ${response.status}`));
return;
}
try {
resolve(JSON.parse(response.responseText));
} catch (error) {
reject(new Error(`Invalid JSON from ${url}`));
}
},
onerror: () => {
reject(new Error(`Network request failed for ${url}`));
}
});
});
}
function setReplyBoxValue(replyBox, value) {
const descriptor = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value");
if (descriptor?.set) {
descriptor.set.call(replyBox, value);
} else {
replyBox.value = value;
}
replyBox.dispatchEvent(new Event("input", { bubbles: true }));
replyBox.dispatchEvent(new Event("change", { bubbles: true }));
}
function insertInlineTranslation(anchorNode, translated, sourceLanguage) {
if (!(anchorNode instanceof HTMLElement) || !anchorNode.parentElement) {
return;
}
const helper = document.createElement("div");
helper.className = "mrc-inline-translation";
helper.innerHTML = `
<div class="mrc-inline-translation-label">${escapeHtml(getTranslateLabel())} (${escapeHtml(sourceLanguage)} -> ${escapeHtml(STATE.settings.editorLanguage)})</div>
<div class="mrc-inline-translation-text">${escapeHtml(translated)}</div>
`;
anchorNode.insertAdjacentElement("afterend", helper);
}
function clearInlineTranslations() {
document.querySelectorAll(".mrc-inline-translation").forEach((node) => node.remove());
}
function renderResult(html) {
const container = STATE.root?.querySelector('[data-role="results"]');
if (!container) {
return;
}
container.innerHTML = `<div class="mrc-result">${html}</div>`;
}
function setStatus(message, isError = false) {
const node = STATE.root?.querySelector('[data-role="status"]');
if (!node) {
return;
}
node.textContent = message;
node.dataset.error = isError ? "true" : "false";
}
function normalizeLanguage(value) {
return String(value || "").trim().toLowerCase();
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function injectStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.mrc-root {
margin: 6px 0 8px;
padding-top: 5px;
border-top: 1px solid #e3e8ef;
font: 12px/1.35 "Segoe UI", Tahoma, sans-serif;
color: #4d5f73;
}
.mrc-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.mrc-row-top {
justify-content: space-between;
gap: 10px;
}
.mrc-kicker {
font-weight: 700;
color: #5c6e81;
}
.mrc-status {
margin-left: auto;
font-size: 11px;
color: #8b4b3f;
}
.mrc-status[data-error="true"] {
color: #b94a48;
}
.mrc-template {
display: inline-flex;
align-items: center;
gap: 5px;
}
.mrc-template span {
width: 10px;
font-weight: 700;
}
.mrc-template select,
.mrc-root button,
.mrc-header-translate {
height: 28px;
border: 1px solid #cfd5de;
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #f5f7fa 100%);
color: #566779;
font: inherit;
box-sizing: border-box;
}
.mrc-template select {
min-width: 102px;
max-width: 136px;
padding: 3px 8px;
}
.mrc-root button,
.mrc-header-translate {
padding: 4px 9px;
cursor: pointer;
}
.mrc-row-ai button {
border-color: #bfd3ea;
background: linear-gradient(180deg, #ffffff 0%, #eef4fb 100%);
}
.mrc-ai-label {
font-weight: 700;
color: #5c6e81;
margin-right: 2px;
}
.mrc-results {
margin-top: 6px;
}
.mrc-result {
border-left: 2px solid #ccd6e2;
background: #fafbfd;
padding: 5px 7px;
font-size: 11px;
}
.mrc-inline-translation {
margin: 5px 0 8px;
padding: 6px 8px;
border-left: 3px solid #7fb1e6;
background: #f7fbff;
border-radius: 6px;
color: #2a4a67;
font: 12px/1.45 "Segoe UI", Tahoma, sans-serif;
}
.mrc-inline-translation-label {
font-weight: 700;
margin-bottom: 3px;
}
.mrc-header-translate {
margin-left: 8px;
vertical-align: middle;
}
`;
document.head.appendChild(style);
}
})();