// ==UserScript==
// @name MapsGraderSheet
// @namespace grader.lock.sheet
// @version 1.9.10
// @description Grade Google Maps places, keep a local saved json places list and sync with Google Sheets. Improved UI flow for updating an already graded place: click Saved/Gespeichert, remove previous grade, retry Save/Speichern until list reappears, then pick new grade. Added German address parsing for Postleitzahl and Stadt. Robust, conservative place-name handling to avoid transient "0" labels.
// @match https://www.google.com/maps/*
// @grant GM_xmlhttpRequest
// @connect script.google.com
// @connect script.googleusercontent.com
// @connect maps.googleapis.com
// ==/UserScript==
(() => {
'use strict';
// Toggle to true only while debugging locally
const DEBUG = true;
// --- NEW: SHEET COLUMN MAPPING ---
const SHEET_COLUMNS = {
recordNumber: 1,
email: 2,
firma: 3,
adresse: 4,
postleitzahl: 5,
stadt: 6,
telefon: 7,
website: 8,
mapsLink: 9,
kategorie: 10,
latLon: 11,
googleMapsLabel: 12,
id: 13,
managingDirector: 14
};
let currentDetailsFetchId = 0;
let lastShownPlaceName = '';
// --- NEW: ADDRESS PARSING FUNCTIONS ---
// robust parser for postal code and city from a typical German address
function parsePostalCodeAndCity(raw) {
if (!raw || typeof raw !== 'string') return { postalCode: '', city: '' };
const s = raw.replace(/\u00A0/g, ' ').trim();
const postalMatch = s.match(/(\d{5})/);
let postal = postalMatch ? postalMatch[1] : '';
let city = '';
if (postal) {
const rx = new RegExp('\\b' + postal + '\\s+([\\p{L}\\s\\.]+?)(?=,|$)', 'u');
const cm = s.match(rx);
if (cm && cm[1]) {
city = cm[1].trim();
}
}
if (!city && postal) {
const fallback = s.split(postal)[1] || '';
const m = fallback.match(/^\s*([^\u002C]+)/);
if (m && m[1]) city = m[1].trim();
}
if (!postal) {
const parts = s.split(',');
if (parts.length >= 2) {
const candidate = parts[1].trim();
const m2 = candidate.match(/(\d{5})\s+(.+)/);
if (m2) {
postal = m2[1];
city = m2[2].replace(/,.*$/,'').trim();
} else {
const m3 = candidate.match(/(\d{5})/);
if (m3) postal = m3[1];
const after = candidate.replace(/^\d{5}\s*/,'').replace(/,.*$/,'').trim();
if (after) city = after;
}
}
}
city = city.replace(/\bDeutschland\b/i, '').replace(/\bGermany\b/i,'').trim();
return { postalCode: postal || '', city: city || '' };
}
// helper to set Postleitzahl and Stadt and create Lat hyphen Lon key safely
function enrichRecordWithPostalCityAndLatLon(rec, addressString, lat, lng) {
const parsed = parsePostalCodeAndCity(addressString || '');
rec['Postleitzahl'] = parsed.postalCode || '';
rec['Stadt'] = parsed.city || '';
const latLonKey = 'Lat' + String.fromCharCode(45) + 'Lon';
rec[latLonKey] = (isFinite(lat) && isFinite(lng)) ? `${lat},${lng}` : '';
return rec;
}
// --- END NEW FUNCTIONS ---
// tolerant grade read
function getGradeFromRecord(rec) {
if (!rec) return '';
const g = rec['Kategorie'] || rec['Category (A / B / C / D)'] || '';
return String(g || '').toUpperCase().trim();
}
// New small helper: only accept real grades
function sanitizeGrade(raw) {
const s = String(raw || '').toUpperCase().trim();
if (s === 'A' || s === 'B' || s === 'C' || s === 'D') return s;
return '';
}
const SHEET_ID = '1d8WyNvMr8n7irWSJPRopnrN_1hBpH-bEhH7lvuNYrqE';
const FETCH_URL = 'https://script.google.com/macros/s/AKfycbxTjZvDCju60CDux4_OrpyA9UFJ__ARUbSqpdJAkxHxVeMpoJKyBvUwdFp6L71hq_2t/exec';
const ADD_URL = 'https://script.google.com/macros/s/AKfycbyI7dBt3IBG1zYhIPHUhttzchXFHnSO-5eOKaY1vcB7W9F0v4qaeT4qvanm7XWMOiRJ/exec';
const UPDATE_URL = 'https://script.google.com/macros/s/AKfycbzcCUneoE-AMUm0fWmI7gzQHY-eDYyhfjxrVxveRkks3D__63j_-TKFWGx_Lwg7Y4co/exec';
const GOOGLE_KEY = 'AIzaSyCvHUfmrjFCV8CfIU_uPH-0n68ooEX9xd8';
const COLORS = { A:'#2ecc71', B:'#f1c40f', C:'#ff8c42', D:'#e74c3c' };
const LOCAL_KEY = 'saved_json_places';
let ALLPLACESJSON = [];
const INDEX = { map:new Map(), pos:new Map(), loaded:false, loading:null };
let persistenceObserver = null;
let insertLock = false;
let lastPlaceKey = computePlaceKey();
let nameDomObserver = null;
let lastCanonicalHref = getCanonicalHref();
let lastDomName = extractPlaceNameFromDOM();
// caches to avoid repeated API calls
const DETAILS_CACHE = new Map();
const DETAILS_PROMISES = new Map();
function ensureFont() {
if (!document.getElementById('montLink')) {
const l = document.createElement('link');
l.id = 'montLink';
l.rel = 'stylesheet';
l.href = 'https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap';
document.head.appendChild(l);
}
}
// fetch helpers with optional logging
function fetchJSON(url) {
return new Promise(resolve => {
try {
if (DEBUG) console.log('fetchJSON request', url);
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: r => {
try {
if (DEBUG) console.log('fetchJSON response status', r.status);
const parsed = JSON.parse(r.responseText);
resolve(parsed);
} catch (err) {
if (DEBUG) console.error('fetchJSON parse error', err, 'raw', r.responseText);
resolve(null);
}
},
onerror: err => {
if (DEBUG) console.error('fetchJSON network error', err, url);
resolve(null);
}
});
} catch (e) {
if (DEBUG) console.error('fetchJSON unexpected error', e, url);
resolve(null);
}
});
}
function postJSON(url, body) {
return new Promise(resolve => {
try {
if (DEBUG) console.log('postJSON request', url, body);
GM_xmlhttpRequest({
method: 'POST',
url: url,
data: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
onload: r => {
try {
if (DEBUG) console.log('postJSON response', url, r.status);
let parsed = null;
try { parsed = JSON.parse(r.responseText); } catch(e){}
resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, text: r.responseText, json: parsed });
} catch (e) {
if (DEBUG) console.error('postJSON onload handling error', e);
resolve({ ok: r.status >= 200 && r.status < 300, status: r.status, text: r.responseText });
}
},
onerror: e => {
if (DEBUG) console.error('postJSON network error', e, url);
resolve({ ok: false, status: 0, text: '' });
}
});
} catch(e) {
if (DEBUG) console.error('postJSON unexpected error', e, url);
resolve({ ok: false, status: 0, text: '' });
}
});
}
function getCanonicalHref() {
try {
const link = document.querySelector('link[rel="canonical"]');
if (link && link.href) return String(link.href);
const meta = document.querySelector('meta[property="og:url"]');
if (meta && meta.content) return String(meta.content);
} catch(e){}
return '';
}
function getCurrentCid() {
const canonical = getCanonicalHref();
const href = canonical || location.href;
try {
const url = new URL(href);
const cidParam = url.searchParams.get('cid');
if (cidParam && /^\d+$/.test(cidParam)) return cidParam;
const m = href.match(/0x[0-9a-fA-F]+:0x([0-9a-fA-F]+)/);
if (m && m[1]) return BigInt('0x'+m[1]).toString();
} catch {}
return null;
}
function computePlaceKey() {
try {
const canonical = getCanonicalHref() || location.href;
const cid = (function(){
try {
const u = new URL(canonical);
const cidParam = u.searchParams.get('cid');
if (cidParam && /^\d+$/.test(cidParam)) return cidParam;
} catch(e){}
const m = canonical.match(/0x[0-9a-fA-F]+:0x([0-9a-fA-F]+)/);
if (m && m[1]) return BigInt('0x'+m[1]).toString();
return null;
})();
if (cid) return 'cid:' + cid;
const dataMatch = canonical.match(/\/data=(!?[^\/?#]+)/);
if (dataMatch && dataMatch[1]) return 'data:' + dataMatch[1];
const placeMatch = canonical.match(/\/place\/([^\/?#]+)/);
if (placeMatch && placeMatch[1]) return 'place:' + decodeURIComponent(placeMatch[1]);
const u = new URL(canonical);
return 'url:' + u.origin + u.pathname;
} catch (e) {
return 'url:' + location.origin;
}
}
// local storage helpers
function loadLocalSaved() {
try {
const raw = localStorage.getItem(LOCAL_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
} catch(e){}
return null;
}
function saveLocalPlaces(arr) {
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(arr));
return true;
} catch(e){ return false; }
}
// fetch sheet and respect local saved list
function fetchSheet() {
if (INDEX.loaded) return Promise.resolve();
if (INDEX.loading) return INDEX.loading;
const local = loadLocalSaved();
if (local && Array.isArray(local) && local.length) {
ALLPLACESJSON = local.slice();
INDEX.map.clear(); INDEX.pos.clear();
ALLPLACESJSON.forEach((r,i) => {
const id = String(r.ID||'');
INDEX.map.set(id, r);
INDEX.pos.set(id, i);
});
}
INDEX.loading = fetchJSON(`${FETCH_URL}?sheetID=${encodeURIComponent(SHEET_ID)}`)
.then(j => {
const rows = Array.isArray(j?.data) ? j.data : [];
if (!rows.length && ALLPLACESJSON.length) {
INDEX.loaded = true;
return;
}
if (!ALLPLACESJSON.length) {
ALLPLACESJSON = rows.slice();
INDEX.map.clear(); INDEX.pos.clear();
ALLPLACESJSON.forEach((r,i) => {
const id = String(r.ID||'');
INDEX.map.set(id, r);
INDEX.pos.set(id, i);
});
saveLocalPlaces(ALLPLACESJSON);
INDEX.loaded = true;
return;
}
const localIds = ALLPLACESJSON.map(x => String(x.ID||''));
const sheetIds = rows.map(x => String(x.ID||''));
let differs = false;
if (localIds.length !== sheetIds.length) differs = true;
else {
for (let i=0;i {
const id = String(r.ID||'');
INDEX.map.set(id, r);
INDEX.pos.set(id, i);
});
saveLocalPlaces(ALLPLACESJSON);
} else {
INDEX.map.clear(); INDEX.pos.clear();
ALLPLACESJSON.forEach((r,i) => {
const id = String(r.ID||'');
INDEX.map.set(id, r);
INDEX.pos.set(id, i);
});
}
INDEX.loaded = true;
})
.catch(()=>{ INDEX.loaded = true; ALLPLACESJSON = ALLPLACESJSON || []; });
return INDEX.loading;
}
// improved and defensive place-details loader
function getPlaceDetails(cid, forceFetch = false) {
if (!cid) return Promise.resolve(null);
if (forceFetch) {
DETAILS_CACHE.delete(cid);
DETAILS_PROMISES.delete(cid);
}
if (DETAILS_CACHE.has(cid) && !forceFetch) {
if (DEBUG) console.log('getPlaceDetails cache hit for', cid);
return Promise.resolve(DETAILS_CACHE.get(cid));
}
if (DETAILS_PROMISES.has(cid) && !forceFetch) {
if (DEBUG) console.log('getPlaceDetails waiting for in flight promise for', cid);
return DETAILS_PROMISES.get(cid);
}
const base = 'https://maps.googleapis.com/maps/api/place/details/json';
const params = [
'cid=' + encodeURIComponent(cid),
'fields=' + encodeURIComponent('name,formatted_address,formatted_phone_number,website,geometry,types'),
'key=' + encodeURIComponent(GOOGLE_KEY)
].join('&');
const url = base + '?' + params;
const p = fetchJSON(url).then(j => {
const result = (j && j.status === 'OK') ? j.result : null;
if (result) {
DETAILS_CACHE.set(cid, result);
} else {
DETAILS_CACHE.delete(cid);
}
DETAILS_PROMISES.delete(cid);
return result;
}).catch(e => {
DETAILS_PROMISES.delete(cid);
return null;
});
DETAILS_PROMISES.set(cid, p);
return p;
}
function createProgressBar() {
const wrap = document.createElement('div');
wrap.style.cssText='display:flex;align-items:center;justify-content:center;width:100%;height:110px;';
const bg = document.createElement('div');
bg.style.cssText='width:60%;height:8px;background:#e0e0e0;border-radius:8px;overflow:hidden;';
const fill = document.createElement('div');
fill.style.cssText='height:100%;width:0%;border-radius:8px;background:linear-gradient(90deg,#6be585 0%,#18a0fb 100%);';
bg.appendChild(fill);
wrap.appendChild(bg);
wrap._timer = null;
wrap._start = () => {
if (wrap._timer) return;
let p=0, fwd=true;
wrap._timer = setInterval(()=>{
p = fwd ? p+2 : p-2;
if(p>=100) fwd=false;
if(p<=0) fwd=true;
fill.style.width = p+'%';
}, 18);
};
wrap._stop = () => { clearInterval(wrap._timer); wrap._timer=null; fill.style.width='0%'; };
return wrap;
}
function styleButton(btn, color, active) {
btn.style.cssText = `
flex:1; padding:8px 0; font-weight:700; border-radius:3px;
font-family:Montserrat,Arial; cursor:pointer;
border:1px solid ${color};
background:${ active ? color : '#fff' };
color:${ active ? '#fff' : color };
`;
}
function buildCatalog() {
const wrap = document.createElement('div');
wrap.id = 'graderBox';
wrap.dataset.mapsgrader = '1';
wrap.style.cssText = `
background:#fff; border:1px solid #e7e7e7; border-radius:8px;
margin:10px 0 14px 0; padding:12px; font:14px Montserrat,Arial;
box-shadow:0 1px 6px rgba(0,0,0,0.06);
width:100%;
z-index:1000;
`;
const head = document.createElement('div');
head.id = 'gHead';
head.style.cssText = 'font-weight:700; margin-bottom:8px;';
head.textContent = '(loading…)';
wrap.appendChild(head);
const content = document.createElement('div');
content.id = 'gContent';
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:8px;';
const btnA = document.createElement('button');
btnA.type = 'button';
btnA.textContent = 'A';
btnA.dataset.g = 'A';
btnA.setAttribute('aria-label', 'grade A');
const btnB = document.createElement('button');
btnB.type = 'button';
btnB.textContent = 'B';
btnB.dataset.g = 'B';
btnB.setAttribute('aria-label', 'grade B');
const btnC = document.createElement('button');
btnC.type = 'button';
btnC.textContent = 'C';
btnC.dataset.g = 'C';
btnC.setAttribute('aria-label', 'grade C');
const btnD = document.createElement('button');
btnD.type = 'button';
btnD.textContent = 'D';
btnD.dataset.g = 'D';
btnD.setAttribute('aria-label', 'grade D');
row.appendChild(btnA);
row.appendChild(btnB);
row.appendChild(btnC);
row.appendChild(btnD);
content.appendChild(row);
wrap.appendChild(content);
const loader = createProgressBar();
loader.id = 'gLoader';
loader.style.display = 'none';
wrap.appendChild(loader);
const banner = document.createElement('div');
banner.id = 'gBanner';
banner.style.cssText = `
display:none; background:${COLORS.A}; color:#fff; font-weight:700;
padding:10px; border-radius:6px; margin-bottom:8px;
text-align:center; opacity:0; transition:opacity .4s;
`;
banner.textContent = '✓ Graded';
wrap.insertBefore(banner, content);
const buttons = { A: btnA, B: btnB, C: btnC, D: btnD };
return { wrap, head, content, loader, banner, buttons };
}
function showLoader(cat, on) {
cat.content.style.display = on ? 'none' : '';
cat.loader.style.display = on ? '' : 'none';
on ? cat.loader._start() : cat.loader._stop();
}
function flashSuccess(cat) {
cat.banner.style.display = '';
cat.banner.style.opacity = '1';
cat.wrap.style.borderColor = COLORS.A;
setTimeout(()=> cat.banner.style.opacity = '0', 1600);
setTimeout(()=>{
cat.banner.style.display = 'none';
cat.wrap.style.borderColor = '#e7e7e7';
},2000);
}
function applyButtonStyles(buttons, current) {
Object.entries(buttons).forEach(([letter,btn]) =>
styleButton(btn, COLORS[letter], letter === current)
);
}
function ensureButtonsReady(cat, currentGrade) {
return new Promise(resolve => {
let attempts = 0;
function tick() {
attempts++;
const btns = cat && cat.buttons;
const allExist = btns && btns.A && btns.B && btns.C && btns.D;
if (allExist) {
applyButtonStyles(btns, currentGrade);
resolve(true);
return;
}
if (attempts >= 8) {
resolve(false);
return;
}
setTimeout(tick, 80 + attempts * 30);
}
tick();
});
}
function waitFor(selOrFn, timeout=6000) {
return new Promise(res => {
const t0 = Date.now();
(function loop() {
try {
const el = typeof selOrFn==='function' ? selOrFn() : document.querySelector(selOrFn);
if (el) return res(el);
if (Date.now() > timeout + t0) return res(null);
} catch(e){}
requestAnimationFrame(loop);
})();
});
}
function findButtonByTextOrAria(targetText) {
const all = Array.from(document.querySelectorAll('button, [role="button"]')).filter(Boolean);
for (const el of all) {
try {
const aName = el.getAttribute('aria' + String.fromCharCode(45) + 'label') || '';
if (aName.trim() === targetText) return el;
} catch(e){}
try {
const txt = (el.textContent||'').trim();
if (txt === targetText) return el;
} catch(e){}
}
return null;
}
function findSaveButtonOnce() {
let found = null;
found = findButtonByTextOrAria('Save') || findButtonByTextOrAria('Speichern');
if (found) return found;
const all = Array.from(document.querySelectorAll('button, [role="button"]'));
for (const b of all) {
const txt = (b.textContent||'').trim();
if (txt === 'Save' || txt === 'Speichern') return b;
}
return null;
}
function waitForSaveButton(timeout=5000) {
const t0 = Date.now();
return new Promise(res => {
(function loop() {
const b = findSaveButtonOnce();
if (b) return res(b);
if (Date.now() > t0 + timeout) return res(null);
setTimeout(loop, 120);
})();
});
}
function getActionMenuById() {
return document.getElementById('action' + String.fromCharCode(45) + 'menu');
}
async function openListAndSelect(labelToClick, tryTimeout=6000) {
const saveBtn = await waitForSaveButton(3000);
if (!saveBtn) return false;
try { saveBtn.click(); } catch(e){ try { saveBtn.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} }
const menu = await waitFor(getActionMenuById, tryTimeout);
if (!menu) return false;
const items = Array.from(menu.querySelectorAll('[role="menuitemradio"], [role="menuitem"], .MMWRwe, .fxNQSd')).filter(Boolean);
for (const it of items) {
const labelEl = it.querySelector('.mLuXec');
const label = (labelEl && labelEl.textContent) ? labelEl.textContent.trim() : (it.textContent||'').trim();
if (label && label.toUpperCase() === (labelToClick||'').toUpperCase()) {
try { it.click(); } catch(e){ try { it.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} }
return true;
}
}
return false;
}
// Normalize and validate place name candidates
function normalizeNameRaw(n) {
if (!n || typeof n !== 'string') return '';
// replace NBSP and zero width spaces etc
const cleaned = String(n).replace(/[\u00A0\u200B\uFEFF]/g, ' ').replace(/\s+/g, ' ').trim();
return cleaned;
}
function validatePlaceName(n) {
const cleaned = normalizeNameRaw(n);
if (!cleaned) return '';
if (cleaned === '0') return '';
if (/^\d+$/.test(cleaned)) return '';
if (cleaned.length < 3) return '';
return cleaned;
}
// central safe setter for catalog header
function setCatalogHeader(c, candidate) {
try {
const v = validatePlaceName(candidate);
if (v) {
c.head.textContent = v;
lastShownPlaceName = v;
return true;
}
// candidate invalid: do not overwrite an existing valid header
const existing = (c.head && c.head.textContent) ? normalizeNameRaw(c.head.textContent) : '';
if (validatePlaceName(existing)) {
// keep existing valid header
return false;
}
// no existing valid header: prefer lastShownPlaceName if valid
if (validatePlaceName(lastShownPlaceName)) {
c.head.textContent = lastShownPlaceName;
return false;
}
// final fallback: stable placeholder
c.head.textContent = '(place)';
return false;
} catch (e) {
try { c.head.textContent = '(place)'; } catch(e){}
return false;
}
}
// Helper to extract street + number from formatted address (first comma-separated segment)
function extractStreetNumberFromFormatted(formatted) {
if (!formatted || typeof formatted !== 'string') return '';
const seg = formatted.split(',')[0].trim();
// Very small sanitization: remove NBSP / zero-widths, collapse spaces
const cleaned = seg.replace(/[\u00A0\u200B\uFEFF]/g,' ').replace(/\s+/g,' ').trim();
// If the cleaned segment is too short or looks like a postal code only, return empty
if (!cleaned || cleaned === '0' || /^\d+$/.test(cleaned) || cleaned.length < 3) return '';
return cleaned;
}
async function autoUpdateGradeInMapsUI(prevGrade, newGrade) {
function findButtonByTextOrAriaLocal(targetText) {
const all = Array.from(document.querySelectorAll('button, [role="button"]'));
for (const el of all) {
try {
const a = el.getAttribute('aria' + String.fromCharCode(45) + 'label') || '';
if (a.trim() === targetText) return el;
} catch(e){}
try {
if ((el.textContent || '').trim() === targetText) return el;
} catch(e){}
}
return null;
}
function getActionMenuLocal() {
return document.getElementById('action' + String.fromCharCode(45) + 'menu');
}
async function waitForMenuLocal(timeout = 4000) {
const t0 = Date.now();
return new Promise(res => {
(function loop() {
const m = getActionMenuLocal();
if (m) return res(m);
if (Date.now() > t0 + timeout) return res(null);
setTimeout(loop, 80);
})();
});
}
async function selectLabelFromOpenMenu(label, menuTimeout = 3000) {
const menu = await waitForMenuLocal(menuTimeout);
if (!menu) return false;
const items = Array.from(menu.querySelectorAll('[role="menuitemradio"], [role="menuitem"], .MMWRwe, .fxNQSd'));
for (const it of items) {
const labelEl = it.querySelector('.mLuXec');
const itemLabel = (labelEl && labelEl.textContent) ? labelEl.textContent.trim() : (it.textContent || '').trim();
if (itemLabel && itemLabel.toUpperCase() === (label||'').toUpperCase()) {
try { it.click(); } catch(e){ try { it.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} }
return true;
}
}
return false;
}
let opener = findButtonByTextOrAriaLocal('Gespeichert') || findButtonByTextOrAriaLocal('Saved');
if (!opener) {
opener = findButtonByTextOrAriaLocal('Save') || findButtonByTextOrAriaLocal('Speichern');
}
if (!opener) return false;
try { opener.click(); } catch(e){ try { opener.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} }
await waitForMenuLocal(2500);
if (prevGrade) {
await selectLabelFromOpenMenu(prevGrade, 2500);
}
let menuAppeared = false;
const MAX_ATTEMPTS = 12;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const saveBtn = findButtonByTextOrAriaLocal('Save') || findButtonByTextOrAriaLocal('Speichern');
if (saveBtn) {
try { saveBtn.click(); } catch(e){ try { saveBtn.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} }
} else {
try { opener.click(); } catch(e){ try { opener.dispatchEvent(new MouseEvent('click', { bubbles:true })); } catch(e){} }
}
const menu = await waitForMenuLocal(1800 + attempt * 120);
if (menu) { menuAppeared = true; break; }
await new Promise(r => setTimeout(r, 140 + attempt * 100));
}
if (!menuAppeared) {
const finalTry = await selectLabelFromOpenMenu(newGrade, 2000);
return finalTry;
}
const picked = await selectLabelFromOpenMenu(newGrade, 3000);
return picked;
}
function removeAllCatalogsImmediate() {
const existing = Array.from(document.querySelectorAll('#graderBox'));
existing.forEach(el=>{
try { el.remove(); } catch(e){}
});
}
function findInsertionTarget() {
const mainPanel = document.querySelector('.m6QErb.WNBkOb.XiKgde[role="main"]') || null;
let tabBar = null;
if (mainPanel) tabBar = mainPanel.querySelector('[role="tablist"]');
if (!tabBar) tabBar = document.querySelector('[role="tablist"]');
const nameBlock = document.querySelector('.lMbq3e') || document.querySelector('h1.DUwDvf') || null;
return { mainPanel, tabBar, nameBlock };
}
async function doAddToSheet(rec) {
const res = await postJSON(ADD_URL, { SheetID:SHEET_ID, Field:rec });
if (DEBUG) console.log('doAddToSheet result', res);
return !!(res && res.ok);
}
async function doUpdateSheetRow(rowIdx, column, newValue) {
const res = await postJSON(UPDATE_URL, {
sheetId: SHEET_ID,
row: String(rowIdx),
column: column,
newValue: newValue
});
if (DEBUG) console.log('doUpdateSheetRow result', rowIdx, column, newValue, res);
return !!(res && res.ok);
}
function extractPlaceNameFromDOM() {
// selectors that typically hold the place title; normalize text before returning
const selectors = [
'h1.DUwDvf',
'.DUwDvf.lfPIob',
'.lMbq3e h1.DUwDvf',
'.a5H0ec',
'.DUwDvf'
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el && el.textContent) {
const raw = el.textContent;
const cleaned = normalizeNameRaw(raw);
if (!cleaned) continue;
if (cleaned === '0' || /^\d+$/.test(cleaned)) continue;
if (cleaned.length < 3) continue;
return cleaned;
}
}
const metaTitle = document.querySelector('meta[property="og:title"]')?.content;
if (metaTitle) {
const t = normalizeNameRaw(String(metaTitle));
if (t && t !== '0' && !/^\d+$/.test(t) && t.length >= 3) return t;
}
return '';
}
// panel observer kept light to avoid heavy processing
let panelObserver = null;
function startPanelObserver() {
stopPanelObserver();
const target = document.querySelector('body');
if (!target) return;
panelObserver = new MutationObserver(muts => {
if (panelObserver._timer) clearTimeout(panelObserver._timer);
panelObserver._timer = setTimeout(() => {
panelObserver._timer = null;
recomputeAndMaybeHandle();
}, 160);
});
try {
// observe only childList and subtree to reduce characterData attr churn
panelObserver.observe(target, { childList:true, subtree:true });
} catch(e){}
}
function stopPanelObserver() {
if (panelObserver) {
try { panelObserver.disconnect(); } catch(e){}
if (panelObserver._timer) { clearTimeout(panelObserver._timer); panelObserver._timer = null; }
panelObserver = null;
}
}
function recomputeAndMaybeHandle() {
try {
const canonical = getCanonicalHref();
const domName = extractPlaceNameFromDOM();
const key = computePlaceKey();
const canonicalChanged = (canonical !== lastCanonicalHref);
const keyChanged = (key !== lastPlaceKey);
const domNameChanged = (domName && domName !== lastDomName && domName !== '(place)');
if (keyChanged || canonicalChanged || domNameChanged) {
lastCanonicalHref = canonical;
lastDomName = domName;
lastPlaceKey = key;
if (key.startsWith('cid:') || key.startsWith('data:') || key.startsWith('place:')) {
try { handlePlaceNavigation(); } catch(e){}
} else {
removeAllCatalogsImmediate();
stopPersistenceWatcher();
}
}
} catch(e){}
}
// add this helper to catch SPA navigation reliably
function watchUrlChanges() {
// wrap pushState and replaceState so we notice programmatic navigations
const _push = history.pushState;
const _replace = history.replaceState;
history.pushState = function() {
const res = _push.apply(this, arguments);
try { window.dispatchEvent(new Event('mapsUrlChange')); } catch(e){}
return res;
};
history.replaceState = function() {
const res = _replace.apply(this, arguments);
try { window.dispatchEvent(new Event('mapsUrlChange')); } catch(e){}
return res;
};
// on popstate or our synthetic event, react immediately
window.addEventListener('popstate', () => {
try { window.dispatchEvent(new Event('mapsUrlChange')); } catch(e){}
});
// our custom event handler: always recompute key and force refresh
window.addEventListener('mapsUrlChange', () => {
try {
const newKey = computePlaceKey();
// if key changed, or even if same key but URL changed, force handle
if (newKey !== lastPlaceKey) {
lastPlaceKey = newKey;
handlePlaceNavigation().catch(()=>{});
} else {
// sometimes the key is equal but content changed; still force refresh
handlePlaceNavigation().catch(()=>{});
}
} catch(e){}
});
}
async function insertCatalogSingle() {
if (insertLock) return null;
insertLock = true;
try {
ensureFont();
removeAllCatalogsImmediate();
const { mainPanel, tabBar, nameBlock } = findInsertionTarget();
const c = buildCatalog();
if (tabBar && tabBar.parentElement) {
try { tabBar.parentElement.insertBefore(c.wrap, tabBar); } catch(e){ (mainPanel || document.body).appendChild(c.wrap); }
} else if (nameBlock && nameBlock.parentElement) {
try { nameBlock.parentElement.insertBefore(c.wrap, nameBlock.nextElementSibling); } catch(e){ (mainPanel || document.body).appendChild(c.wrap); }
} else {
(mainPanel || document.body).appendChild(c.wrap);
}
// Prefer the last shown stable name if available, else DOM, else placeholder
const domName = extractPlaceNameFromDOM();
const initialCandidate = validatePlaceName(lastShownPlaceName) || validatePlaceName(domName) || '';
if (!initialCandidate) {
// ensure we have a stable visible placeholder rather than an invalid value
c.head.textContent = '(place)';
} else {
setCatalogHeader(c, initialCandidate);
}
// set up a debounced observer to update the header only when there is a validated new name
if (nameDomObserver) {
try { nameDomObserver.disconnect(); } catch(e){}
nameDomObserver = null;
}
// choose an element to observe that holds the place title
const observedEl = document.querySelector('h1.DUwDvf') || document.querySelector('.lMbq3e') || document.body;
if (observedEl) {
nameDomObserver = new MutationObserver(() => {
if (nameDomObserver._t) clearTimeout(nameDomObserver._t);
nameDomObserver._t = setTimeout(() => {
nameDomObserver._t = null;
try {
const freshRaw = extractPlaceNameFromDOM();
const fresh = validatePlaceName(freshRaw);
// update only when we have a valid and genuinely different name
if (fresh && fresh !== lastShownPlaceName) {
setCatalogHeader(c, fresh);
}
} catch(e){}
}, 180);
});
try {
nameDomObserver.observe(observedEl, { childList:true, subtree:true, characterData:true });
} catch(e){}
}
const cidNowForStyle = getCurrentCid();
const cachedRec = cidNowForStyle ? INDEX.map.get(cidNowForStyle) : null;
// sanitize initial grade before applying
const initialGrade = sanitizeGrade(getGradeFromRecord(cachedRec));
await ensureButtonsReady(c, initialGrade);
Object.values(c.buttons).forEach(btn=>{
if (btn._mapsgraderBound) return;
btn._mapsgraderBound = true;
btn.addEventListener('click', async () => {
debugger;
const grade = btn.dataset.g;
const cidNow = getCurrentCid();
if (DEBUG) console.log('grade button clicked', grade, 'cid', cidNow);
if (!cidNow) return;
showLoader(c, true);
const existing = INDEX.map.get(cidNow);
const prevGrade = getGradeFromRecord(existing);
if (existing) {
existing['Kategorie'] = grade;
existing['Category (A / B / C / D)'] = grade;
const pos = INDEX.pos.get(cidNow);
if (typeof pos === 'number') {
ALLPLACESJSON[pos] = existing;
} else {
ALLPLACESJSON.push(existing);
INDEX.pos.set(cidNow, ALLPLACESJSON.length - 1);
}
const savedOk = saveLocalPlaces(ALLPLACESJSON);
if (DEBUG) console.log('saved local places ok', savedOk, 'items', ALLPLACESJSON.length);
showLoader(c, false);
flashSuccess(c);
applyButtonStyles(c.buttons, sanitizeGrade(grade));
setTimeout(()=> applyButtonStyles(c.buttons, sanitizeGrade(grade)), 80);
try { await autoUpdateGradeInMapsUI(prevGrade, grade); } catch(e){}
(async () => {
try {
const rowIdx = INDEX.pos.get(cidNow) + 2;
await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.kategorie), grade);
const address = existing['Adresse'];
const lat = existing['Lat'] || '';
const lng = existing['Lng'] || '';
if (address) {
const parsed = parsePostalCodeAndCity(address);
if (parsed.postalCode) {
await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.postleitzahl), parsed.postalCode);
}
if (parsed.city) {
await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.stadt), parsed.city);
}
const latLonKey = 'Lat' + String.fromCharCode(45) + 'Lon';
const latLonValue = (isFinite(lat) && isFinite(lng)) ? `${lat},${lng}` : '';
if (latLonValue) {
await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.latLon), latLonValue);
}
}
} catch(e){}
})();
return;
} else {
let details = null;
try { details = await getPlaceDetails(cidNow); } catch(e){ details = null; }
if (!details) { showLoader(c,false); return; }
const recordNumber = ALLPLACESJSON.length + 1;
const email = details.email && /\S+@\S+\.\S+/.test(details.email)
? details.email
: `${recordNumber}@mail.de`;
const loc = details.geometry?.location || {};
// NEW: only keep street + number as 'Adresse'
// Extract postal code and city from full formatted address BEFORE truncating it
const fullAddress = details.formatted_address || '';
const streetOnly = extractStreetNumberFromFormatted(fullAddress) || '';
const { postalCode, city } = parsePostalCodeAndCity(fullAddress);
const rec = {
'Record Number': recordNumber,
'Email': email,
'Firma': details.name || extractPlaceNameFromDOM() || '',
'Adresse': streetOnly,
'Postleitzahl': postalCode || '',
'Stadt': city || '',
'Telefon': details.formatted_phone_number || '',
'Website': details.website || '',
'Maps Link': `https://www.google.com/maps?cid=${cidNow}`,
'Kategorie': grade || '',
'Google Maps Label': details.name || '',
'ID': cidNow,
'Managing Director': '',
'Lead Source': 'GMaps Leads',
'Deal stage': 'Rohlead'
};
rec['Category (A / B / C / D)'] = rec['Kategorie'];
if (isFinite(loc.lat)) rec['Lat'] = loc.lat;
if (isFinite(loc.lng)) rec['Lng'] = loc.lng;
// Lat/Lon/other enrichments
enrichRecordWithPostalCityAndLatLon(rec, fullAddress, rec['Lat'], rec['Lng']);
ALLPLACESJSON.push(rec);
INDEX.map.set(cidNow, rec);
INDEX.pos.set(cidNow, ALLPLACESJSON.length - 1);
const savedOk = saveLocalPlaces(ALLPLACESJSON);
if (DEBUG) console.log('saved local places ok', savedOk, 'items', ALLPLACESJSON.length);
showLoader(c, false);
flashSuccess(c);
applyButtonStyles(c.buttons, sanitizeGrade(grade));
setTimeout(()=> applyButtonStyles(c.buttons, sanitizeGrade(grade)), 80);
try { await autoUpdateGradeInMapsUI('', grade); } catch(e){}
(async () => {
try {
await doAddToSheet(rec);
const rowIdx = INDEX.pos.get(cidNow) + 2;
const parsed = parsePostalCodeAndCity(rec['Adresse']);
if (parsed.postalCode) {
await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.postleitzahl), parsed.postalCode);
}
if (parsed.city) {
await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.stadt), parsed.city);
}
const latLonKey = 'Lat' + String.fromCharCode(45) + 'Lon';
const latLonValue = (isFinite(rec['Lat']) && isFinite(rec['Lng'])) ? `${rec['Lat']},${rec['Lng']}` : '';
if (latLonValue) {
await doUpdateSheetRow(rowIdx, String(SHEET_COLUMNS.latLon), latLonValue);
}
} catch(e){}
})();
return;
}
});
});
try {
if (c._observer) { try { c._observer.disconnect(); } catch(e){} c._observer = null; }
const obs = new MutationObserver(() => {
if (obs._t) clearTimeout(obs._t);
obs._t = setTimeout(() => {
obs._t = null;
const cid = getCurrentCid();
const rec = cid ? INDEX.map.get(cid) : null;
const g = sanitizeGrade(getGradeFromRecord(rec));
applyButtonStyles(c.buttons, g);
}, 80);
});
obs.observe(c.wrap, { childList:true, subtree:true });
c._observer = obs;
} catch(e){}
setTimeout(() => {
const cid = getCurrentCid();
const rec = cid ? INDEX.map.get(cid) : null;
const g = sanitizeGrade(getGradeFromRecord(rec));
applyButtonStyles(c.buttons, g);
}, 120);
return c;
} finally {
insertLock = false;
}
}
// NEUE FUNKTION zur Prüfung auf gültigen Namen/Adresse
function isPlaceDataValid(details, domNameFallback) {
console.log(details, domNameFallback);
if (!details) {
// Wenn keine API-Details vorhanden sind, ist nur der DOM-Name der einzige Fallback.
return validatePlaceName(domNameFallback) !== '';
}
// Prüft API-Name und DOM-Fallback
const apiName = validatePlaceName(details.name || '');
const chosenName = apiName || validatePlaceName(domNameFallback || '');
if (!chosenName) return false;
// Prüft API-Adresse. Die Adresse muss mindestens 3 Zeichen haben und darf nicht '0' sein.
// Wir nutzen hier die Logik, die in extractStreetNumberFromFormatted verwendet wird,
// aber ohne nur den ersten Teil zu nehmen, da wir hier nur auf Existenz prüfen.
const address = details.formatted_address;
if (address) {
const cleanedAddress = String(address).replace(/[\u00A0\u200B\uFEFF]/g, ' ').replace(/\s+/g, ' ').trim();
// 1. Leere oder '0'-Adressen blockieren (bestehende Logik)
if (!cleanedAddress || cleanedAddress === '0') return false;
// 2. NEUE PRÜFUNG: Überprüft auf das Vorhandensein einer gültigen 5-stelligen Postleitzahl
// Adressen wie "Speicherstraße 77, 44147 Dortmund" bestehen diesen Test.
// Adressen wie "Lindenhorst, 44 Dortmund-Eving" (ohne 5-stellige PLZ) schlagen hier fehl.
const postalCodeRegex = /\b\d{4,5}\b/;
if (!postalCodeRegex.test(cleanedAddress)) {
if (DEBUG) console.log('Address rejected: Missing 5-digit postal code in:', cleanedAddress);
return false;
}
}
// Wenn Name gültig ist und Adresse entweder existiert oder nicht leer/null/0 ist, ist es gültig.
return true;
}
function startPersistenceWatcher() {
stopPersistenceWatcher();
const obs = new MutationObserver(() => {
const nodes = Array.from(document.querySelectorAll('#graderBox'));
if (nodes.length === 0) {
setTimeout(async () => {
const currentKey = computePlaceKey();
if (!document.querySelector('#graderBox') && (currentKey.startsWith('cid:') || currentKey.startsWith('place:') || currentKey.startsWith('data:'))) {
try {
const c = await insertCatalogSingle();
if (c) {
const cid = getCurrentCid();
const localRec = INDEX.map.get(cid);
if (localRec) {
const nameCandidate = (localRec['Google Maps Label'] || localRec['Firma']) || extractPlaceNameFromDOM() || '(place)';
setCatalogHeader(c, nameCandidate);
const grade = sanitizeGrade(getGradeFromRecord(localRec));
applyButtonStyles(c.buttons, grade);
} else {
const details = cid ? (await getPlaceDetails(cid, true)) : null;
const chosenName = validatePlaceName(details?.name) || validatePlaceName(extractPlaceNameFromDOM()) || '';
if (chosenName) {
setCatalogHeader(c, chosenName);
} else {
// keep stable placeholder
setCatalogHeader(c, '(place)');
}
const grade = sanitizeGrade(getGradeFromRecord(INDEX.map.get(cid)));
applyButtonStyles(c.buttons, grade);
}
}
} catch(e){}
}
}, 120);
} else if (nodes.length > 1) {
nodes.slice(1).forEach(n => { try { n.remove(); } catch(e){} });
}
});
obs.observe(document.body, { childList:true, subtree:true });
persistenceObserver = obs;
}
function stopPersistenceWatcher() {
if (persistenceObserver) {
try { persistenceObserver.disconnect(); } catch(e){}
persistenceObserver = null;
}
}
async function handlePlaceNavigation() {
const cid = getCurrentCid();
if (!cid) {
stopPersistenceWatcher();
removeAllCatalogsImmediate();
lastShownPlaceName = '';
return;
}
// remove old UI then insert fresh catalog
removeAllCatalogsImmediate();
const c = await insertCatalogSingle();
if (!c) return;
// bump fetch id so any earlier API responses are ignored
currentDetailsFetchId = (currentDetailsFetchId || 0) + 1;
const myFetchId = currentDetailsFetchId;
// read DOM early as fallback
const domNameEarly = extractPlaceNameFromDOM();
// try to fetch fresh details from Maps API and force it
let details = null;
try { details = await getPlaceDetails(cid, true); } catch(e){ details = null; }
if (!isPlaceDataValid(details, domNameEarly)) {
if (DEBUG) console.log('Invalid place data (name/address invalid or missing), disabling grader box.');
removeAllCatalogsImmediate();
stopPersistenceWatcher();
stopPanelObserver();
return; // Hält die weitere Verarbeitung an
}
// if this response is stale, bail out from applying its data
if (myFetchId !== currentDetailsFetchId) {
// a newer navigation happened, so ignore this result
return;
}
// prefer API name when valid, fallback to DOM
const apiName = validatePlaceName(details?.name || '');
const domName = validatePlaceName(domNameEarly || '');
const chosen = apiName || domName || '';
// only update if we have a validated name
if (chosen) {
setCatalogHeader(c, chosen);
} else {
// no valid candidate, preserve previous stable header or placeholder
setCatalogHeader(c, '(place)');
}
// enrich local record if needed
const rec = INDEX.map.get(cid);
if (details && rec) {
if (!rec['Adresse'] && details.formatted_address) rec['Adresse'] = details.formatted_address;
if (!rec['Google Maps Label'] && details.name) rec['Google Maps Label'] = details.name;
if (!rec['Lat'] && details.geometry?.location?.lat) rec['Lat'] = details.geometry.location.lat;
if (!rec['Lng'] && details.geometry?.location?.lng) rec['Lng'] = details.geometry.location.lng;
enrichRecordWithPostalCityAndLatLon(rec, rec['Adresse'], rec['Lat'], rec['Lng']);
try { saveLocalPlaces(ALLPLACESJSON); } catch(e){}
}
lastCanonicalHref = getCanonicalHref();
lastPlaceKey = computePlaceKey();
const grade = sanitizeGrade(getGradeFromRecord(INDEX.map.get(cid)));
applyButtonStyles(c.buttons, grade);
startPersistenceWatcher();
startPanelObserver();
}
let lastUrlSeen = location.href;
function checkUrlButIgnoreLatLng() {
const url = location.href;
if (url === lastUrlSeen) return;
lastUrlSeen = url;
const key = computePlaceKey();
if (key === lastPlaceKey) return;
lastPlaceKey = key;
if (key.startsWith('cid:') || key.startsWith('data:') || key.startsWith('place:')) {
handlePlaceNavigation();
} else {
removeAllCatalogsImmediate();
stopPersistenceWatcher();
stopPanelObserver();
}
}
fetchSheet().finally(()=> {
lastPlaceKey = computePlaceKey();
lastCanonicalHref = getCanonicalHref();
lastDomName = extractPlaceNameFromDOM();
if (lastPlaceKey.startsWith('cid:') || lastPlaceKey.startsWith('data:') || lastPlaceKey.startsWith('place:')) {
handlePlaceNavigation();
}
// reduced interval to lower CPU load
setInterval(checkUrlButIgnoreLatLng, 800);
// start robust SPA url watcher
try { watchUrlChanges(); } catch(e){}
});
document.addEventListener('click', e => {
if (e.target.closest && e.target.closest('#graderBox')) {
setTimeout(() => {
const head = document.querySelector('#gHead');
if (head) {
const candidate = validatePlaceName(extractPlaceNameFromDOM()) || '';
if (candidate) {
setCatalogHeader({ head }, candidate);
} else {
// ensure we don't set a bad value
setCatalogHeader({ head }, '(place)');
}
}
}, 120);
}
});
window.addEventListener('beforeunload', () => {
stopPersistenceWatcher();
stopPanelObserver();
removeAllCatalogsImmediate();
if (nameDomObserver) try { nameDomObserver.disconnect(); } catch(e){}
});
})();