1(function initGA() { 2 const GA_ID = "G-VEJD43P16C"; 3 const BLOCKED = ["BI", "KE", "RW", "SS", "TZ", "UG"]; 4 const FLAG = "ga-disable-" + GA_ID; 5 const GEO_KEY = "flookup_geo_country"; 6 const GEO_TTL = 24 * 60 * 60 * 1000; 7 function startGA() { 8 try { 9 window.dataLayer = window.dataLayer || []; 10 window.gtag = function () { 11 window.dataLayer.push(arguments); 12 }; 13 window.gtag("js", new Date()); 14 window.gtag("config", GA_ID); 15 const s = document.createElement("script"); 16 s.async = true; 17 s.src = "https://www.googletagmanager.com/gtag/js?id=" + GA_ID; 18 document.head.appendChild(s); 19 } catch (e) { 20 let detail = "unknown error"; 21 if (e) { 22 if (e.message) { 23 detail = e.message; 24 } 25 } 26 if (window.console) { 27 window.console.warn("Analytics init failed: " + detail); 28 } 29 } 30 } 31 function isBlocked(country) { 32 try { 33 return BLOCKED.indexOf(String(country || "").toUpperCase()) !== -1; 34 } catch (err) { 35 if (window.console) { 36 window.console.warn("Geo check failed.", err); 37 } 38 return false; 39 } 40 } 41 function readGeo() { 42 try { 43 const raw = localStorage.getItem(GEO_KEY); 44 if (!raw) { 45 return null; 46 } 47 const parsed = JSON.parse(raw); 48 if (!parsed.ts || Date.now() - parsed.ts > GEO_TTL) { 49 localStorage.removeItem(GEO_KEY); 50 return null; 51 } 52 return parsed.country || null; 53 } catch (err) { 54 if (window.console) { 55 window.console.warn("Geo cache read failed.", err); 56 } 57 return null; 58 } 59 } 60 function writeGeo(country) { 61 try { 62 localStorage.setItem(GEO_KEY, JSON.stringify({ ts: Date.now(), country: country })); 63 } catch (err) { 64 if (window.console) { 65 window.console.warn("Geo cache write failed.", err); 66 } 67 } 68 } 69 try {
70 let cached = readGeo(); 71 if (cached) { 72 if (isBlocked(cached)) { 73 window[FLAG] = true; 74 return; 75 } 76 startGA(); 77 return; 78 } 79 let timedOut = false; 80 const timer = setTimeout(function () { 81 timedOut = true; 82 startGA(); 83 }, 1500); 84 fetch("/api/geo-country", { cache: "no-store" }) 85 .then(function (r) { 86 if (!r.ok) { 87 throw new Error("geo " + r.status); 88 } 89 return r.json(); 90 }) 91 .then(function (data) { 92 if (timedOut) { 93 return; 94 } 95 clearTimeout(timer); 96 const country = String((data && data.country) || "").toUpperCase(); 97 if (country) { 98 writeGeo(country); 99 } 100 if (isBlocked(country)) { 101 window[FLAG] = true; 102 return; 103 } 104 startGA(); 105 }) 106 .catch(function (err) { 107 if (timedOut) { 108 return; 109 } 110 clearTimeout(timer); 111 if (window.console) { 112 window.console.warn("Geo lookup failed, starting analytics.", err); 113 } 114 startGA(); 115 }); 116 } catch (e) { 117 let detail = "unknown error"; 118 if (e) { 119 if (e.message) { 120 detail = e.message; 121 } 122 } 123 if (window.console) { 124 window.console.warn("Analytics init failed: " + detail); 125 } 126 try { 127 startGA(); 128 } catch (err) { 129 if (window.console) { 130 window.console.warn("Analytics retry failed.", err); 131 } 132 } 133 } 134})(); 135const loc = window.location; 136const pathname = loc.pathname; 137const search = loc.search; 138const TRACKING_PARAMS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "aff", "affiliate", "ref", "source", "src", "campaign", "track", "gclid", "fbclid"]; 139const hasTrackingParam = TRACKING_PARAMS.some((p) => search.includes(p + "=")); 140if (pathname.includes(".html") || hasTrackingParam) { 141 const origin = loc.protocol + "//" + loc.host; 142 let newPath = pathname; 143 let needsPathUpdate = false; 144 if (pathname.endsWith(".html")) { 145 newPath = pathname.replace(".html", "/"); 146 needsPathUpdate = true; 147 } 148 let finalSearchString = search; 149 let needsSearchUpdate = false; 150 if (hasTrackingParam) { 151 const params = new URLSearchParams(search); 152 TRACKING_PARAMS.forEach((p) => params.delete(p)); 153 finalSearchString = params.toString() ? "?" + params.toString() : ""; 154 needsSearchUpdate = true; 155 } 156 if (needsPathUpdate || needsSearchUpdate) { 157 const canonicalURL = origin + newPath + finalSearchString + loc.hash; 158 try { 159 window.history.replaceState(null, "", canonicalURL); 160 window.dispatchEvent( 161 new CustomEvent("canonicalUrlChanged", { 162 detail: { newPath, newSearch: finalSearchString }, 163 }), 164 ); 165 } catch (e) { 166 console.error("Error updating URL:", e); 167 } 168 } 169} 170window.showToast = function (msg) { 171 const toast = document.getElementById("toast"); 172 if (!toast) return; 173 if (window._toastTimer) clearTimeout(window._toastTimer); 174 toast.classList.remove("show"); 175 void toast.offsetWidth; 176 toast.textContent = msg; 177 toast.classList.add("show"); 178 window._toastTimer = setTimeout(function () { 179 toast.classList.remove("show"); 180 window._toastTimer = null; 181 }, 3000); 182}; 183window.smoothScrollTo = function (target) { 184 const header = document.querySelector("header"); 185 const headerHeight = header ? header.offsetHeight : 0; 186 const offset = target === 0 ? 0 : headerHeight + 24; 187 const start = window.pageYOffset || document.documentElement.scrollTop; 188 const end = typeof target === "number" ? target : target.getBoundingClientRect().top + start - offset; 189 const distance = end - start; 190 const duration = 600; 191 let startTime = null; 192 function step(timestamp) { 193 if (!startTime) startTime = timestamp; 194 const elapsed = timestamp - startTime; 195 const progress = Math.min(elapsed / duration, 1); 196 const eased = 1 - Math.pow(1 - progress, 3); 197 window.scrollTo(0, start + distance * eased); 198 if (elapsed < duration) { 199 requestAnimationFrame(step); 200 } 201 } 202 if (Math.abs(distance) < 2) { 203 window.scrollTo(0, end); 204 } else { 205 requestAnimationFrame(step); 206 } 207}; 208function getIsoWeek(date) { 209 const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); 210 const dayNum = (d.getUTCDay() + 6) % 7; 211 d.setUTCDate(d.getUTCDate() - dayNum + 3); 212 const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4)); 213 const firstDayNum = (firstThursday.getUTCDay() + 6) % 7; 214 firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3); 215 return 1 + Math.round((d - firstThursday) / (7 * 24 * 3600 * 1000)); 216} 217document.addEventListener("DOMContentLoaded", function () { 218 function setActiveMenuItem() { 219 const currentPath = window.location.pathname; 220 const navLinks = document.querySelectorAll(".nav-links a"); 221 navLinks.forEach((link) => { 222 const href = link.getAttribute("href");
223 if (!href && !link.classList.contains("dropbttn")) { 224 return; 225 } 226 link.classList.remove("active"); 227 const normalizedPath = currentPath.endsWith("/") ? currentPath : currentPath + "/"; 228 if (href) { 229 const normalizedHref = href.endsWith("/") ? href : href + "/"; 230 if (href === "/" || href === "/index.html") { 231 const homePaths = ["/", "/index.html", "/index/", "/index"]; 232 if (homePaths.includes(currentPath)) { 233 link.classList.add("active"); 234 } 235 } else if (normalizedPath.startsWith(normalizedHref)) { 236 link.classList.add("active"); 237 } 238 } 239 }); 240 } 241 setActiveMenuItem(); 242 window.addEventListener("canonicalUrlChanged", setActiveMenuItem); 243 const featuredBlogCardsContainer = document.querySelector(".featured-blog-cards"); 244 if (featuredBlogCardsContainer) { 245 async function getAllBlogArticles() { 246 try { 247 const response = await fetch("/blog/"); 248 if (!response.ok) { 249 throw new Error("Failed to fetch blog index"); 250 } 251 const html = await response.text(); 252 const parser = new DOMParser(); 253 const doc = parser.parseFromString(html, "text/html"); 254 const tiles = Array.from(doc.querySelectorAll(".blog-grid .blog-tile, .blog-tile")); 255 return tiles 256 .map((tile) => { 257 let href = tile.getAttribute("href") || tile.querySelector("a")?.getAttribute("href"); 258 if (!href) { 259 return null; 260 } 261 const title = tile.querySelector("h3")?.textContent?.trim() || ""; 262 const description = tile.querySelector("p")?.textContent?.trim() || ""; 263 const published = parsePublished(tile.querySelector(".tile-meta")?.textContent || ""); 264 if (!title || !description) { 265 return null; 266 } 267 return { href, title, description, published }; 268 }) 269 .filter(Boolean); 270 } catch { 271 return []; 272 } 273 } 274 function parsePublished(text) { 275 const match = (text || "").match(/(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})/i); 276 if (!match) { 277 return 0; 278 } 279 const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "
279November", "December"]; 280 const month = months.findIndex((m) => m.toLowerCase() === match[1].toLowerCase()); 281 return new Date(Number(match[2]), month, 1).getTime(); 282 } 283 function renderFeaturedCards(pool) { 284 const slotCount = Math.min(featuredBlogCardsContainer.children.length || 3, pool.length); 285 const today = new Date(); 286 const seed = today.getFullYear() * 100 + getIsoWeek(today); 287 function seededShuffle(array, seed) { 288 let arr = array.slice(); 289 let m = arr.length, 290 t, 291 i; 292 let s = seed; 293 while (m) { 294 i = Math.floor(Math.abs(Math.sin(s++) * 10000) % m--); 295 t = arr[m]; 296 arr[m] = arr[i]; 297 arr[i] = t; 298 } 299 return arr; 300 } 301 const pinnedGuide = pool.filter((item) => item.href === "/blog/what-is-data-cleaning/"); 302 const compareHrefs = ["/blog/power-query-vs-flookup-data-wrangler/", "/blog/ablebits-power-tools-vs-flookup-data-wrangler/", "/blog/alteryx-vs-flookup-data-wrangler/"]; 303 const newestCompare = compareHrefs.map((href) => pool.find((item) => item.href === href)).find(Boolean); 304 const pinned = newestCompare ? pinnedGuide.concat([newestCompare]) : pinnedGuide; 305 const rest = pool.filter((item) => item.href !== "/blog/what-is-data-cleaning/" && (!newestCompare || item.href !== newestCompare.href)); 306 const fresh = rest 307 .slice() 308 .sort((a, b) => (b.published || 0) - (a.published || 0)) 309 .slice(0, 6); 310 const shuffled = seededShuffle(fresh, seed); 311 const selected = pinned.concat(shuffled.slice(0, Math.max(0, slotCount - pinned.length))); 312 const section = featuredBlogCardsContainer.closest("section"); 313 const countMeta = section && section.querySelector('meta[itemprop="numberOfItems"]'); 314 if (countMeta) { 315 countMeta.setAttribute("content", String(selected.length)); 316 } 317 function escapeHtmlAttr(s) { 318 return String(s).replace(/[&<>"']/g, function (ch) { 319 return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch]; 320 }); 321 } 322 function isSafeHref(href) { 323 return typeof href === "string" && href.startsWith("/") && !href.startsWith("//") && !href.includes('"') && !href.includes("'") && href.length < 300; 324 } 325 featuredBlogCardsContainer.innerHTML = selected 326 .map(function (item, idx) { 327 const safeHref = isSafeHref(item.href) ? escapeHtmlAttr(item.href) : "/"; 328 const safeTitle = escapeHtmlAttr(item.title); 329 const safeDesc = escapeHtmlAttr(item.description); 330 return '<a href="' + safeHref + '" class="blog-card" itemprop="itemListElement" itemscope itemtype="http://schema.org/ListItem" title="' + safeTitle + '"><meta itemprop="position" content="' + (idx + 1) + '" /><div itemprop="item" itemscope itemtype="http://schema.org/Thing"><h3 itemprop="name">' + safeTitle + '</h3><p itemprop="description">' + safeDesc + '</p><span class="blog-card-meta">Read guide</span></div></a>'; 331 }) 332 .join(""); 333 } 334 getAllBlogArticles().then(renderFeaturedCards); 335 } 336 const operationsGrid = document.getElementById("operations-grid"); 337 if (operationsGrid) { 338 const operationPool = [ 339 { href: "/documentation/menu-functions/#fuzzy-match", title: "Fuzzy Matching", description: "Reconcile records across datasets despite typos, abbreviations and inconsistent formatting." }, 340 { href: "/documentation/menu-functions/#deduplicate", title: "Deduplication", description: "Remove near-duplicate rows that exact matching misses, including phonetic and typo variants." }, 341 { href: "/documentation/templates/", title: "Workflow Templates", description: "One-click AP reconciliation, order-to-shipping and user list consolidation jobs." }, 342 { href: "/documentation/learn-from-examples/", title: "Learn from Examples", description: "Show dirty-clean example pairs; the system learns the transformation with no formulas." }, 343 { href: "/documentation/schedule-functions/", title: "Data Cleaning Automation", description: "Schedule deduplication and standardisation from every 15 minutes to daily." }, 344 { href: "/documentation/sheets-ai-assistant/", title: "Sheets AI Assistant", description: "Describe your data problem in plain language; get formulas or execution." }, 345 { href: "/documentation/custom-functions/", title: "Data Cleaning Spreadsheet Formulas", description: "Type FLOOKUP, FUZZYSIM, DEDUPE and ULIST directly into cells." }, 346 { href: "/documentation/menu-functions/#profile-column", title: "Column Profiling", description: "Scan a column for row counts, nulls, duplicates and format inconsistencies." }, 347 { href: "/documentation/menu-functions/#enrich-data", title: "Enrich Data", description: "Add ISO c
347ountries, US states, company suffixes and TLDs next to your input." }, 348 { href: "/documentation/menu-functions/#standardize", title: "Text Standardisation", description: "Strip punctuation, diacritics and stop words into one consistent format." }, 349 { href: "/documentation/menu-functions/#compare", title: "Compare Similarity", description: "Score how alike two values are with a percentage similarity readout." }, 350 { href: "/documentation/menu-functions/#merge-row", title: "Fuzzy Merge Rows", description: "Merge near-duplicate rows into one, keeping the best value from each." }, 351 { href: "/documentation/menu-functions/#extract-unique", title: "Extract Unique Rows", description: "Pull unique rows from messy data into an auditable working copy." }, 352 { href: "/documentation/menu-functions/#fuzzy-conditional-formatting", title: "Fuzzy Conditional Formatting", description: "Highlight near-duplicate groups with fuzzy matching before you deduplicate." }, 353 ]; 354 function renderOperations(pool) { 355 const slotCount = Math.min(9, pool.length); 356 const today = new Date(); 357 const seed = today.getFullYear() * 100 + (today.getMonth() + 1); 358 function seededShuffle(array, seed) { 359 let arr = array.slice(); 360 let m = arr.length, 361 t, 362 i; 363 let s = seed; 364 while (m) { 365 i = Math.floor(Math.abs(Math.sin(s++) * 10000) % m--); 366 t = arr[m]; 367 arr[m] = arr[i]; 368 arr[i] = t; 369 } 370 return arr; 371 } 372 const pinned = pool.filter((item) => item.href === "/documentation/templates/"); 373 const rest = pool.filter((item) => item.href !== "/documentation/templates/"); 374 const selected = pinned.concat(seededShuffle(rest, seed).slice(0, Math.max(0, slotCount - pinned.length))); 375 const section = operationsGrid.closest("section"); 376 const countMeta = section && section.querySelector('meta[itemprop="numberOfItems"]'); 377 if (countMeta) { 378 countMeta.setAttribute("content", String(selected.length)); 379 } 380 operationsGrid.innerHTML = selected 381 .map( 382 (item, idx) => ` 383 <a href="${item.href}" class="op-item" itemprop="itemListElement" itemscope itemtype="http://schema.org/ListItem" title="${item.title}"> 384 <meta itemprop="position" content="${idx + 1}" /> 385 <span class="op-num" aria-hidden="true">${idx + 1}</span> 386 <div class="op-body" itemprop="item" itemscope itemtype="http://schema.org/Thing"> 387 <h3 itemprop="name">${item.title}</h3> 388 <p itemprop="description">${item.description}</p> 389 </div> 390 </a>`, 391 ) 392 .join(""); 393 } 394 renderOperations(operationPool); 395 } 396 const toggleButtons = document.querySelectorAll(".mobile-menu-toggle"); 397 const overlay = document.querySelector(".mobile-overlay"); 398 const navLinks = document.querySelector(".nav-links"); 399 function toggleMobileMenu(open) { 400 if (!navLinks) { 401 return; 402 } 403 const isActive = open ?? !navLinks.classList.contains("active"); 404 navLinks.classList.toggle("active", isActive); 405 toggleButtons.forEach((bttn) => { 406 bttn.classList.toggle("active", isActive); 407 bttn.setAttribute("aria-expanded", isActive); 408 }); 409 if (overlay) { 410 overlay.classList.toggle("active", isActive); 411 } 412 } 413 toggleButtons.forEach((toggle) => { 414 toggle.addEventListener("click", () => toggleMobileMenu()); 415 }); 416 if (overlay) { 417 overlay.addEventListener("click", () => toggleMobileMenu(false)); 418 } 419 const currentYearEl = document.getElementById("currentYear"); 420 if (currentYearEl) { 421 const year = new Date().getFullYear(); 422 currentYearEl.textContent = "2018-" + year; 423 } 424 document.querySelectorAll('.toc a[href^="#"]').forEach(function (link) { 425 link.addEventListener("click", function (e) { 426 const id = link.getAttribute("href").slice(1); 427 const target = document.getElementById(id); 428 if (target) { 429 e.preventDefault(); 430 window.smoothScrollTo(target); 431 } 432 }); 433 }); 434}); 435(function ipCacheHelper() { 436 const KEY = "flookup_ip_cache"; 437 const TTL = 24 * 60 * 60 * 1000; 438 window.readIpCache = function () { 439 try { 440 const raw = localStorage.getItem(KEY); 441 if (!raw) return null; 442 const parsed = JSON.parse(raw); 443 if (!parsed.ts || Date.now() - parsed.ts > TTL) { 444 localStorage.removeItem(KEY); 445 return null; 446 } 447 return parsed.data; 448 } catch (e) { 449 return null; 450 } 451 }; 452 window.writeIpCache = function (data) { 453 try { 454 const payload = JSON.stringify({ ts: Date.now(), data: data }); 455 localStorage.setItem(KEY, payload); 456 sessionStorage.setItem(KEY, payload); 457 } catch (e) {} 458 }; 459 window.fetchIpCached = function (url) {
460 let cached = window.readIpCache(); 461 if (cached) return Promise.resolve(cached); 462 try { 463 const sess = sessionStorage.getItem(KEY); 464 if (sess) { 465 const sp = JSON.parse(sess); 466 if (sp.ts && Date.now() - sp.ts < TTL) return Promise.resolve(sp.data); 467 } 468 } catch (e) {} 469 return fetch(url, { cache: "force-cache" }) 470 .then(function (r) { 471 if (!r.ok) throw new Error("ip fetch " + r.status); 472 return r.json(); 473 }) 474 .then(function (data) { 475 window.writeIpCache(data); 476 return data; 477 }); 478 }; 479})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.