1 2 3<!DOCTYPE html> 4<html lang="en" data-bs-theme="dark"> 5<head> 6 <meta charset="utf-8" /> 7 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 <meta name="theme-color" content="#97C906" /> <!-- Add theme color meta tag --> 9 <link rel="preconnect" href="https://clerk.goblin.tools" crossorigin /> 10 <link rel="preload" as="script" href="https://clerk.goblin.tools/npm/@clerk/clerk-js@5/dist/clerk.browser.js" crossorigin /> 11 <link rel="manifest" href="https://goblin.tools/site.webmanifest?v=_Aqfz49MTuLLrWBw0wBVhuBPd7kSzmQqdz6kwHZsMGI"> <!-- Add manifest link --> 12 <meta name="google-play-app" content="app-id=com.goblintools" /> 13 <meta name="apple-itunes-app" content="app-id=6449003064" /> 14 15 16 <title>Goblin Tools Pro - Goblin Tools</title> 17
17<script> 18 // Country flag emoji support probe. Windows' Segoe UI Emoji carries no 19 // flag glyphs, so a regional indicator pair renders as two boxed 20 // letters ("BE") instead of the Belgian flag, in Chrome and Edge. 21 // Where that happens we tag the document and site.css swaps in a small 22 // Twemoji flag subset. Where flags already work (macOS, iOS, Android, 23 // Firefox-on-Windows) the class is absent, the font family is never 24 // referenced, and the file is never downloaded. 25 // 26 // Inline and synchronous on purpose: this has to land before first 27 // paint or the picker flashes lettered boxes and then restyles. 28 (function () { 29 function flagsRenderNatively() { 30 try { 31 var c = document.createElement('canvas'); 32 if (!c.getContext) return true; 33 var ctx = c.getContext('2d', { willReadFrequently: true }); 34 if (!ctx || !ctx.fillText) return true; 35 c.width = 64; c.height = 40; 36 ctx.textBaseline = 'top'; 37 ctx.font = '24px sans-serif'; 38 var B = String.fromCodePoint(0x1F1E7); 39 var E = String.fromCodePoint(0x1F1EA); 40 // Two regional indicators. On a flag-capable platform they 41 // fuse into a single flag glyph. 42 ctx.fillText(B + E, 0, 0); 43 var fused = c.toDataURL(); 44 ctx.clearRect(0, 0, 64, 40); 45 // The same two characters held apart by a zero-width space 46 // so they cannot fuse. Zero width means each glyph lands in 47 // the same place either way, so an identical bitmap proves 48 // no fusion happened above - i.e. there is no flag glyph. 49 ctx.fillText(B + '\u200B' + E, 0, 0); 50 return fused !== c.toDataURL(); 51 } catch (e) { 52 // Canvas blocked (privacy extensions, some WebViews). 53 // Assume flags are fine rather than push a font at everyone. 54 return true; 55 } 56 } 57 if (!flagsRenderNatively()) { 58 document.documentElement.className += ' gt-flag-font'; 59 } 60 })(); 61 </script>
61 62 <link rel="stylesheet" href="https://goblin.tools/css/site.css?v=vuSF6G4hK4-NbjcYE2F-4IlooSjqDf4X-RO1BeyXHB8" /> 63 <link rel="stylesheet" href="https://goblin.tools/GoblinTools.styles.css?v=wZe2_6p5mAuKVo1OONyUxG0sCI0LP419qiq_zGT8hlY" /> 64 <!-- Add favicon and app icons --> 65 <link rel="apple-touch-icon" sizes="180x180" href="https://goblin.tools/assets/apple-touch-icon.png?v=utJZDTQaz5vgeZO3b_ioTKY8cNnQJUn7a-oOmBNKeDU"> 66 <link rel="icon" type="image/png" sizes="32x32" href="https://goblin.tools/assets/favicon-32x32.png?v=E7_GSkDe7-nfJfjZhkjwm7IUd-E_5sCjpEAUolNcwzI"> 67 <link rel="icon" type="image/png" sizes="16x16" href="https://goblin.tools/assets/favicon-16x16.png?v=yBr4TsokmoWVS5YnO7TUSZS1z7Il2K00M5eALOs_zEo"> 68 <link rel="mask-icon" href="/assets/safari-pinned-tab.svg?v=OOMGIxrXngBvwSdbBJawfdcCHk_wxy6nZCFORTlZQ18" color="#CCE969"> 69
69<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
69 70
70<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js"></script>
70 71
71<script src="https://goblin.tools/ext/toastify-js.js?v=WCvsu2K7qBKFNHhVz3An24MbI-ZBnInEJ9BeTDzqB0E"></script>
71 72
72<script src="https://goblin.tools/ext/popper.min.js?v=whL0tQWoY1Ku1iskqPFvmZ-CHsvmRWx_PIoEvIeWh4I"></script>
72 73
73<script src="https://goblin.tools/ext/tippy.min.js?v=Pw_nDrJszyj2iHoZLinTjdfvfC8HmnMwStQt3HvtN94"></script>
73 74
74<script src="https://goblin.tools/ext/readable-seconds.js?v=oS4VfJqsqvV1Gb1iNnWd3LXpXBiRvTBQSnl2u7WR2Lk"></script>
74 75
75<script src="https://goblin.tools/ext/timestring.js?v=7F6aVBfH_OqpS4awzs3EoOIlFj0xfrzsSr8ZJz-GgfE"></script>
75 76 77 <link rel="stylesheet" href="https://goblin.tools/ext/all.min.css?v=R6pa_zpbhz9IjJIAXKP_0Kk53cRwfsjdik4Ojf9lOrQ"> 78 <link href="https://goblin.tools/ext/bootstrap.min.css?v=Fu5_PVNGJlC70y4mPEjA6nWVdPz2IMaBrXGQCJEsRho" rel="stylesheet"> 79 <link href="https://goblin.tools/ext/toastify.min.css?v=eFUVNXYEdruIhSK1ZToG6bt7FwYzdFdKkEBEZrpM7lg" rel="stylesheet"> 80 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css"> 81
81<script src="https://goblin.tools/ext/bootstrap.bundle.min.js?v=6o_gIaSs5PZ4b-zEGPcLZY_C3ALRNuj-XGq2tipG1dA"></script>
81 82
82<script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>
82 83
83<script src="https://goblin.tools/ext/jquery.ui.touch-punch.min.js?v=LPzJt5IMKR_MqvmQWIsMvpCaABdFC5nKhd_jJBmSsw8"></script>
83 84
84<script> 85 window.datafast = window.datafast || function () { 86 window.datafast.q = window.datafast.q || []; 87 window.datafast.q.push(arguments); 88 }; 89 </script>
89 90
90<script defer 91 data-website-id="dfid_XoTQiRKEvxkL0NO60ppQ8" 92 data-domain="goblin.tools" 93 data-api-url="https://dtfst.goblin.tools/api/events" 94 src="https://dtfst.goblin.tools/js/script.cookieless.js"> 95 </script>
95 96
96<script> 97 // Single page-version constant, sourced from appsettings.json's 98 // AppVersion. Used by the marker at the bottom, by any JS that 99 // wants to know what build it's part of (analytics, debug 100 // logs), and compared against the SW's reported version to 101 // detect a stale controller. 102 window.APP_VERSION = '3.14.1'; 103 </script>
103 104
104<script> 105 (function () { 106 var htmlElement = document.querySelector('html'); 107 var theme = localStorage.getItem('theme'); 108 if (theme === 'dark') { 109 htmlElement.setAttribute('data-bs-theme', 'dark'); 110 } else { 111 htmlElement.setAttribute('data-bs-theme', 'light'); 112 } 113 })(); 114 </script>
114 115 <!-- Appzi: Capture Insightful Feedback --> 116
116<script async src="https://w.appzi.io/w.js?token=ac3uB"></script>
116 117 <style> 118 .navbar .navbar-collapse { 119 text-align: center; 120 } 121 122 @media (max-width: 1200px) { 123 .nav-item { 124 border-radius: 6px !important; 125 } 126 127 .navbar-brand img { 128 height: 40px !important; 129 } 130 } 131 132 .navbar-brand img { 133 height: 60px; 134 padding: 0px 10px; 135 } 136 137 .brand-wrap { 138 position: relative; 139 display: inline-block; 140 } 141 142 .supporter-tag { 143 position: absolute; 144 top: 100%; 145 left: 10px; 146 transform: translateY(-10%) rotate(-4deg); 147 transform-origin: center center; 148 background: var(--bs-body-bg); 149 border: 1px solid var(--bs-border-color); 150 border-radius: 4px; 151 padding: 4px 12px 5px; 152 text-decoration: none; 153 line-height: 1.15; 154 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); 155 z-index: 10; 156 text-align: center; 157 white-space: nowrap; 158 transition: transform 0.15s ease, box-shadow 0.15s ease; 159 } 160 161 .supporter-tag:hover { 162 transform: translateY(-10%) rotate(-4deg) scale(1.04); 163 box-shadow: 0 4px 10px rgba(0, 0, 0, 0.18); 164 } 165 166 .supporter-tag-label { 167 display: block; 168 font-size: 0.65rem; 169 text-transform: uppercase; 170 letter-spacing: 0.05em; 171 color: var(--bs-secondary-color); 172 } 173 174 .supporter-tag-name { 175 display: block; 176 font-size: 0.95rem; 177 font-weight: 700; 178 color: var(--bs-primary); 179 } 180 181 182 .nav-item:first-of-type { 183 border-radius: 6px 0 0 6px; 184 } 185 186 187 .nav-item:last-of-type { 188 border-radius: 0 6px 6px 0; 189 } 190 191 .app-button { 192 width: 100px; 193 height: 100px; 194 display: flex; 195 flex-direction: column; 196 align-items: center; 197 justify-content: center; 198 padding: var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x); 199 font-size: var(--bs-nav-link-font-size); 200 font-weight: var(--bs-nav-link-font-weight); 201 color: var(--bs-nav-link-color) !important; 202 text-decoration: none; 203 background: 0 0; 204 border: 0; 205 transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out; 206 } 207 208 .app-button:hover { 209 color: var(--bs-nav-link-hover-color) !important; 210 } 211 212 #teacher-tools img { 213 width: 120px; 214 height: auto; 215 margin-left: 0px; 216 margin-bottom: 2px; 217 vertical-align: text-bottom; 218 } 219 220 [data-bs-theme=light] #teacher-tools img { 221 content: url('/assets/TT logo black.png'); 222 } 223 224 [data-bs-theme=dark] #teacher-tools img { 225 content: url('/assets/TT logo white.png'); 226 } 227 228 .links-section { 229 margin-top: 2rem; 230 } 231 232 .links-grid { 233 display: grid; 234 grid-template-columns: repeat(3, 1fr); 235 gap: 1.5rem; 236 margin-bottom: 5px; 237 } 238 239 .top-app-text { 240 display: none; 241 } 242 243 .bottom-app-text { 244 display: block; 245 } 246 247 248 @media (max-width: 768px) { 249 .links-grid { 250 grid-template-columns: 1fr; 251 gap: 1rem; 252 } 253 254 .top-app-text { 255 display: block; 256 } 257 258 .bottom-app-text { 259 display: none; 260 } 261 } 262 263 .links-category { 264 text-align: center; 265 } 266 267 .links-category h3 { 268 font-size: 1.2rem; 269 margin-bottom: 1rem; 270 } 271 272 .links-buttons { 273 display: flex; 274 flex-direction: column; 275 align-items: center; 276 gap: 0.5rem; 277 } 278 279 @media (max-width: 768px) { 280 .links-buttons { 281 display: grid; 282 grid-template-columns: repeat(2, 1fr); 283 gap: 0.5rem; 284 } 285 } 286 287 .support-row { 288 display: grid; 289 grid-template-columns: 1fr 1fr; 290 gap: 0.5rem; 291 width: 100%; 292 } 293 294 @media (max-width: 768px) { 295 .support-row { 296 grid-column: 1 / -1; 297 } 298 } 299 300 .app-link { 301 display: inline-flex; 302 align-items: center; 303 padding: 0.5rem 1rem; 304 border: 1px solid var(--bs-border-color); 305 border-radius: 0.25rem; 306 text-decoration: none; 307 color: var(--bs-body-color) !important; 308 transition: all 0.2s ease; 309 width: 100%; 310 justify-content: center; 311 } 312 313 .app-link:hover { 314 background-color: var(--bs-tertiary-bg); 315 text-decoration: none; 316 color: var(--bs-body-color) !important; 317 } 318 319 .app-link i { 320 margin-right: 0.5rem; 321 font-size: 1.5rem; 322 } 323 324 /* "Learn more" segmented control: Privacy / Terms / Refunds share the 325 shape of a single .app-link. Each shows only its icon until hovered, 326 when it expands to reveal its label and squeezes the other two. */ 327 .seg-group { 328 display: flex; 329 width: 100%; 330 border: 1px solid var(--bs-border-color); 331 border-radius: 0.25rem; 332 overflow: hidden; 333 } 334 335 .seg-group .seg { 336 flex: 1 1 0; 337 min-width: 0; 338 display: flex; 339 align-items: center; 340 justify-content: center; 341 padding: 0.5rem 0.3rem; 342 text-decoration: none; 343 color: var(--bs-body-color) !important; 344 white-space: nowrap; 345 overflow: hidden; 346 transition: flex-grow 0.25s ease, background-color 0.2s ease; 347 } 348 349 .seg-group .seg + .seg { 350 border-left: 1px solid var(--bs-border-color); 351 } 352 353 .seg-group .seg i { 354 font-size: 1.5rem; 355 flex-shrink: 0; 356 } 357 358 .seg-group .seg .seg-label { 359 max-width: 0; 360 margin-left: 0; 361 opacity: 0; 362 overflow: hidden; 363 white-space: nowrap; 364 transition: max-width 0.25s ease, margin-left 0.25s ease, opacity 0.2s ease; 365 } 366 367 .seg-group .seg:hover { 368 flex-grow: 3; 369 background-color: var(--bs-tertiary-bg); 370 } 371
372 .seg-group .seg:hover .seg-label { 373 max-width: 8rem; 374 margin-left: 0.4rem; 375 opacity: 1; 376 } 377 378 /* Touch has no hover: stack the block and show all three labels so the 379 legal links are never hidden behind an interaction that can't fire. */ 380 @media (max-width: 768px) { 381 .links-buttons.learn-more-buttons { 382 display: flex; 383 flex-direction: column; 384 } 385 386 .seg-group .seg { 387 padding: 0.5rem; 388 } 389 390 .seg-group .seg i { 391 font-size: 1.25rem; 392 } 393 394 .seg-group .seg .seg-label { 395 max-width: 8rem; 396 margin-left: 0.4rem; 397 opacity: 1; 398 } 399 } 400 401 /* Custom SmartBanner styling - "goblin" design */ 402 .smartbanner--goblin { 403 display: block; 404 position: relative !important; 405 max-width: 1140px; 406 margin: -1px auto 1rem auto; 407 top: auto !important; 408 left: auto !important; 409 right: auto !important; 410 width: 100%; 411 background: var(--bs-body-bg) !important; 412 border: 1px solid var(--bs-border-color) !important; 413 border-radius: 0 0 0.5rem 0.5rem !important; 414 padding: 0.75rem !important; 415 } 416 417 /* Dark mode support */ 418 [data-bs-theme="dark"] .smartbanner--goblin { 419 background: var(--bs-body-bg) !important; 420 } 421 422 /* Style VIEW button to match site's green theme */ 423 .smartbanner--goblin .smartbanner__button { 424 background: var(--bs-body-bg) !important; 425 color: var(--bs-body-color) !important; 426 border-radius: 0.375rem !important; 427 border: 2px solid var(--bs-body-color) !important; 428 padding: 0.5rem 1rem; 429 text-decoration: none; 430 font-weight: 500; 431 transition: all 0.3s ease; 432 top: 50% !important; 433 transform: translateY(-50%) !important; 434 height: 42px; 435 } 436 437 .smartbanner--goblin .smartbanner__button:hover { 438 transform: translateY(-60%) !important; 439 box-shadow: 0 5px 15px rgba(0,0,0,0.2); 440 border-color: var(--bs-primary) !important; 441 text-decoration: none; 442 color: inherit; 443 } 444 445 /* Style the exit button */ 446 .smartbanner--goblin .smartbanner__exit { 447 color: var(--bs-body-color) !important; 448 opacity: 0.6; 449 } 450 451 .smartbanner--goblin .smartbanner__exit:hover { 452 opacity: 1; 453 } 454 455 /* Style the info text */ 456 .smartbanner--goblin .smartbanner__info__title { 457 color: var(--bs-body-color) !important; 458 } 459 460 .smartbanner--goblin .smartbanner__info__author, 461 .smartbanner--goblin .smartbanner__info__price { 462 color: var(--bs-secondary-color) !important; 463 } 464 465 @media (max-width: 1200px) { 466 .smartbanner--goblin { 467 max-width: 100%; 468 margin: 0 0 1rem 0; 469 border-radius: 0; 470 border-left: none !important; 471 border-right: none !important; 472 } 473 } 474 </style> 475
475<script src="https://goblin.tools/js/site.js?v=hnSwx6KHnpqJ83rkH2fDwE19pFwr6fvS2geprCboqRg"></script>
475 476 477
477<script src="https://goblin.tools/js/voice.js?v=vUkdkN6rZwba-HNK-qH0LWjoeip4ZvOHtg4YNbrD7lA"></script>
477 478 <style> 479 body { 480 min-height: 100vh; 481 display: flex; 482 flex-direction: column; 483 margin: 0; 484 } 485 486 .container-wrapper { 487 flex: 1; 488 display: flex; 489 flex-direction: column; 490 } 491 492 .container-wrapper > .container:last-child { 493 margin-top: auto; 494 } 495 496 footer.footer { 497 margin-top: auto; 498 } 499 500 @media (max-width: 768px) { 501 body { 502 margin-bottom: 0; 503 } 504 } 505 </style> 506 <style> 507 .theme-toggle-split { 508 display: inline-flex; 509 align-items: center; 510 border: 1px solid var(--bs-border-color); 511 border-radius: 0.375rem; 512 padding: 0; 513 overflow: hidden; 514 background: var(--bs-body-bg); 515 } 516 517 .theme-toggle-split.theme-toggle-rounded { 518 border-radius: 50px; 519 } 520 521 .theme-toggle-split .theme-icon-wrapper { 522 padding: 0.5rem 1rem; 523 display: flex; 524 align-items: center; 525 justify-content: center; 526 transition: background-color 0.15s ease-in-out; 527 cursor: pointer; 528 } 529 530 .theme-toggle-split .theme-icon-wrapper:hover { 531 background-color: var(--bs-tertiary-bg); 532 } 533 534 .theme-toggle-split .theme-icon-wrapper.inactive { 535 opacity: 0.4; 536 } 537 538 .theme-toggle-split .theme-divider { 539 width: 1px; 540 height: 28px; 541 background-color: var(--bs-border-color); 542 } 543 544 [data-bs-theme="light"] .theme-toggle-split .theme-icon-light { 545 opacity: 1; 546 } 547 548 [data-bs-theme="light"] .theme-toggle-split .theme-icon-dark { 549 opacity: 0.4; 550 } 551 552 [data-bs-theme="dark"] .theme-toggle-split .theme-icon-light { 553 opacity: 0.4; 554 } 555 556 [data-bs-theme="dark"] .theme-toggle-split .theme-icon-dark { 557 opacity: 1; 558 } 559 560 /* Mobile menu spacing */ 561 @media (max-width: 1199px) { 562 .navbar-nav.ms-auto { 563 border-top: 1px solid var(--bs-border-color); 564 padding-top: 1rem; 565 margin-top: 1rem; 566 margin-bottom: 1rem; 567 } 568 } 569 </style> 570 <style> 571 .language-selector-wrapper { 572 position: relative; 573 } 574 575 .language-selector-wrapper .fa-globe { 576 position: absolute; 577 top: 50%; 578 left: 1rem; /* 16px */ 579 transform: translateY(-50%); 580 z-index: 2; /* Ensure icon is above the select box */ 581 pointer-events: none; /* Make the icon non-interactive */ 582 } 583 584 /* Add padding to the Choices.js generated element */ 585 .language-selector-wrapper .choices__inner { 586 padding-left: 2.5rem; /* 40px, adjust as needed */ 587 } 588 589 .choices__list--dropdown .choices__item--selectable[data-select-text], .choices__list[aria-expanded] .choices__item--selectable[data-select-text] { 590 padding-right: 10px !important; 591 } 592 593 @media (max-width: 1200px) { 594 .language-selector-wrapper { 595 padding-left: 10px !important; 596 padding-right: 10px !important; 597 } 598 } 599 600 601 .choices__inner { 602 background-color: var(--bs-body-bg) !important; 603 padding: 1px 7.5px 1.75px 40px !important; 604 border: 1px solid var(--bs-border-color); 605 border-radius: 6px !important; 606 font-size: 14px; 607 min-height: 0 !important; 608 } 609 610 .choices__list--dropdown, .choices__list[aria-expanded] { 611 border-bottom-left-radius: 6px; 612 border-bottom-right-radius: 6px; 613 } 614 615 616 .choices__list--dropdown, .choices__list[aria-expanded], .choices__input { 617 background-color: var(--bs-body-bg) !important; 618 border: 1px solid var(--bs-border-color) !important; 619 z-index: 1000; 620 } 621 622 .choices__list--single { 623 padding: 4px 20px 4px 4px; 624 } 625 626 </style> 627</head> 628<body> 629 <div b-0bai7cs8n1 id="gt-pro-grace" class="gt-pro-grace unprintable" style="display: none;" role="status"> 630 <i b-0bai7cs8n1 class="fas fa-triangle-exclamation"></i>
631 <span b-0bai7cs8n1 id="gt-pro-grace-text"></span> 632 <button b-0bai7cs8n1 type="button" class="btn btn-sm btn-dark" onclick="gtProStartCheckout()"> 633 Renew 634 </button> 635 </div> 636 637 <header b-0bai7cs8n1> 638 <nav b-0bai7cs8n1 class="navbar navbar-expand-xl navbar-toggleable-xl navbar-light border-bottom box-shadow"> 639 <div b-0bai7cs8n1 class="container"> 640 <div b-0bai7cs8n1 class="brand-wrap"> 641 <a class="navbar-brand" href="https://goblin.tools/"><img b-0bai7cs8n1 src="/assets/logo.png" /><span b-0bai7cs8n1 class="printable">goblin.tools</span></a> 642 <a b-0bai7cs8n1 href="/sponsorship" class="supporter-tag d-block unprintable" aria-label="Sponsorship"> 643 <span b-0bai7cs8n1 class="supporter-tag-label">kept free by</span> 644 <span b-0bai7cs8n1 class="supporter-tag-name">our sponsors</span> 645 646 </a> 647 </div> 648 <div b-0bai7cs8n1 class="d-flex d-xl-none ms-auto align-items-stretch gap-2"> 649 <a b-0bai7cs8n1 href="#" class="gt-pro-btn gt-pro-btn-compact unprintable " 650 data-tooltip="Get Goblin Tools Pro" 651 aria-label="Get Goblin Tools Pro"> 652 <i b-0bai7cs8n1 class="fas fa-wand-magic-sparkles gt-pro-icon"></i> 653 <span b-0bai7cs8n1 class="gt-pro-label">Pro</span> 654 </a> 655 <button b-0bai7cs8n1 class="navbar-toggler unprintable" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent" 656 aria-expanded="false" aria-label="Toggle navigation"> 657 <span b-0bai7cs8n1 class="navbar-toggler-icon"></span> 658 </button> 659 </div> 660 661 <div b-0bai7cs8n1 class="navbar-collapse unprintable collapse d-xl-inline-flex justify-content-between"> 662 <ul b-0bai7cs8n1 class="navbar-nav"> 663 <li b-0bai7cs8n1 class="nav-item border shadow-sm "> 664 <a b-0bai7cs8n1 class="nav-link mx-1" href="/Todo" data-tooltip="Break down todo items">Magic ToDo</a> 665 </li> 666 667 <li b-0bai7cs8n1 class="nav-item border shadow-sm "> 668 <a b-0bai7cs8n1 class="nav-link mx-1" href="/Formalizer" data-tooltip="Text transformers for tone">Formalizer</a> 669 </li> 670 <li b-0bai7cs8n1 class="nav-item border shadow-sm "> 671 <a b-0bai7cs8n1 class="nav-link mx-1" href="/Judge" data-tooltip="Read a text for emotion">Judge</a> 672 </li> 673 <li b-0bai7cs8n1 class="nav-item border shadow-sm "> 674 <a b-0bai7cs8n1 class="nav-link mx-1" href="/Taskmaster" data-tooltip="Focus on one task at a time">Taskmaster</a> 675 </li> 676 <li b-0bai7cs8n1 class="nav-item border shadow-sm "> 677 <a b-0bai7cs8n1 class="nav-link mx-1" href="/Professor" data-tooltip="Explain anything">Professor</a> 678 </li> 679 <li b-0bai7cs8n1 class="nav-item border shadow-sm "> 680 <a b-0bai7cs8n1 class="nav-link mx-1" href="/Consultant" data-tooltip="Help me decide">Consultant</a> 681 </li> 682 <li b-0bai7cs8n1 class="nav-item border shadow-sm "> 683 <a b-0bai7cs8n1 class="nav-link mx-1" href="/Estimator" data-tooltip="Guess an activity's timeframe">Estimator</a> 684 </li> 685 <li b-0bai7cs8n1 class="nav-item border shadow-sm "> 686 <a b-0bai7cs8n1 class="nav-link mx-1" href="/Compiler" data-tooltip="Turn a braindump into actions">Compiler</a> 687 </li> 688 <li b-0bai7cs8n1 class="nav-item border shadow-sm "> 689 <a b-0bai7cs8n1 class="nav-link mx-1" href="/Chef" data-tooltip="Create a recipe from ingredients">Chef</a> 690 </li> 691 </ul> 692 <ul b-0bai7cs8n1 class="navbar-nav ms-auto"> 693 <li b-0bai7cs8n1 class="nav-item"> 694 695<div class="d-flex align-items-stretch gap-2"> 696<div class="d-flex flex-column gap-2 flex-grow-1"> 697 <!-- New Centering Wrapper --> 698 <div class="d-flex justify-content-center gap-2"> 699 <!-- Unified Theme Toggle --> 700 <div class="theme-toggle-split theme-toggle" data-tooltip="Switch light/dark mode"> 701 <div class="theme-icon-wrapper theme-icon-light"> 702 <i class="fas fa-sun-bright"></i> 703 </div> 704 <div class="theme-divider"></div> 705 <div class="theme-icon-wrapper theme-icon-dark"> 706 <i class="fas fa-moon"></i> 707 </div> 708 </div> 709 710 <!-- Review Button --> 711 <a href="#" class="theme-toggle-split" onclick="appzi && appzi.openSurvey('3497fc0e-c96e-4299-9393-5ea76e45575a'); return false;" data-tooltip="Feedback" style="text-decoration: none; color: inherit;"> 712 <div class="theme-icon-wrapper"> 713 <i class="fas fa-heart" style="color: #e74c3c;"></i> 714 </div> 715 </a> 716 </div> 717 718 <!-- Unified Language Selector --> 719 <div class="language-selector-wrapper"> 720 <i class="fas fa-globe"></i> 721 <select class="form-select language-selector" aria-label="Select Language"> 722 723 </select> 724 </div> 725</div> 726 727 <a href="/pro" class="gt-pro-btn unprintable " 728 data-tooltip="Get Goblin Tools Pro" 729 aria-label="Get Goblin Tools Pro"> 730 <i class="fas fa-wand-magic-sparkles gt-pro-icon"></i>
731 <span class="gt-pro-label">Pro</span> 732 </a> 733</div> 734 </li> 735 </ul> 736 </div> 737 </div> 738 739 740 </nav> 741 </header> 742 743 744 <div b-0bai7cs8n1 id="smartbanner-container"></div> 745 <div b-0bai7cs8n1 class="container-wrapper"> 746 <div b-0bai7cs8n1 class="container"> 747 <main b-0bai7cs8n1 role="main" class="pb-3 position-relative"> 748 749<div class="container gt-pro-page gt-pro-simple" data-gt-pro-page="pro"> 750 751 <p class="gt-pro-whatsthis"><a href="/pro/about">What's this?</a></p> 752 753 <div class="gt-pro-simple-head"> 754 <h1 class="gt-pro-simple-title">Sign in</h1> 755 <p class="gt-pro-simple-sub">Welcome back.</p> 756 </div> 757 758 <div class="gt-pro-action"> 759 <div class="gt-pro-clerk-wrap"> 760 <div class="gt-pro-clerk-spinner" role="status" style="display: none;"> 761 <div class="spinner-border text-primary"></div> 762 </div> 763 <div id="gt-pro-clerk-signin" class="gt-pro-clerk"></div> 764 </div> 765 766 <div class="gt-pro-switch"> 767 <span>Don't have an account?</span> 768 <a href="/pro/join" class="btn btn-primary w-100 mt-2">Get Goblin Tools Pro</a> 769 </div> 770 771 <div id="gt-pro-page-error" class="alert alert-danger mt-3" style="display: none;" role="alert"></div> 772 </div> 773</div> 774 775 </main> 776 </div> 777 778 <div b-0bai7cs8n1 class="container mb-3"> 779 <hr b-0bai7cs8n1 class="mb-3" /> 780 <div b-0bai7cs8n1 class="links-section"> 781 <div b-0bai7cs8n1 class="links-grid"> 782 <div b-0bai7cs8n1 class="links-category"> 783 <h3 b-0bai7cs8n1>Support</h3> 784 <div b-0bai7cs8n1 class="links-buttons"> 785 <a b-0bai7cs8n1 href="https://ko-fi.com/goblintools" target="_blank" class="app-link"> 786 <i b-0bai7cs8n1 class="fas fa-coffee"></i> 787 Ko-Fi 788 </a> 789 <a b-0bai7cs8n1 href="https://www.patreon.com/GoblinTools" target="_blank" class="app-link"> 790 <i b-0bai7cs8n1 class="fab fa-patreon"></i> 791 Patreon 792 </a> 793 </div> 794 </div> 795 <div b-0bai7cs8n1 class="links-category"> 796 <h3 b-0bai7cs8n1>Mobile Apps</h3> 797 798 <div b-0bai7cs8n1 class="links-buttons"> 799 <a b-0bai7cs8n1 href="https://play.google.com/store/apps/details?id=com.goblintools" target="_blank" class="app-link"> 800 <i b-0bai7cs8n1 class="fab fa-android" style="color: #a4c639;"></i> 801 Android 802 </a> 803 <a b-0bai7cs8n1 href="https://apps.apple.com/app/goblin-tools/id6449003064" target="_blank" class="app-link"> 804 <i b-0bai7cs8n1 class="fab fa-apple"></i> 805 iOS 806 </a> 807 </div> 808 </div> 809 <div b-0bai7cs8n1 class="links-category"> 810 <h3 b-0bai7cs8n1>Learn More</h3> 811 <div b-0bai7cs8n1 class="links-buttons learn-more-buttons"> 812 <a b-0bai7cs8n1 href="/About" class="app-link"> 813 <i b-0bai7cs8n1 class="fas fa-info-circle"></i> 814 About 815 </a> 816 <div b-0bai7cs8n1 class="seg-group" role="group" aria-label="Learn More"> 817 <a b-0bai7cs8n1 href="/Privacy" class="seg" aria-label="Privacy"> 818 <i b-0bai7cs8n1 class="fas fa-shield-alt"></i> 819 <span b-0bai7cs8n1 class="seg-label">Privacy</span> 820 </a> 821 <a b-0bai7cs8n1 href="/Terms" class="seg" aria-label="Terms"> 822 <i b-0bai7cs8n1 class="fas fa-file-contract"></i>
823 <span b-0bai7cs8n1 class="seg-label">Terms</span> 824 </a> 825 <a b-0bai7cs8n1 href="/Refunds" class="seg" aria-label="Refunds"> 826 <i b-0bai7cs8n1 class="fas fa-rotate-left"></i> 827 <span b-0bai7cs8n1 class="seg-label">Refunds</span> 828 </a> 829 </div> 830 </div> 831 </div> 832 833 834 835 </div> 836 <div b-0bai7cs8n1 id="sw-version-marker" class="text-center text-muted small mt-2" style="opacity: 0.5; font-size: 0.7rem; cursor: pointer;" role="button" tabindex="0" title="Click to force refresh (clears caches and reloads)">v3.14.1</div> 837 </div> 838 </div> 839 </div> 840 841
841<script> 842 843 // Add event listener for theme toggle - support multiple instances 844 document.querySelectorAll('.theme-toggle').forEach(function(toggle) { 845 toggle.addEventListener('click', toggleTheme); 846 }); 847 848 // Apply the stored theme on page load 849 document.addEventListener('DOMContentLoaded', function () { 850 var storedTheme = localStorage.getItem('theme'); 851 if (storedTheme) { 852 setTheme(storedTheme); 853 } else { 854 setTheme('light') 855 } 856 857 var isOpera = (!!window.opr && !!opr.addons) || !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0; 858 if (isOpera) { 859 var pwabutton = $("#pwa-button") 860 pwabutton.addClass("disabled"); 861 pwabutton.text("Not supported in your browser!") 862 863 } 864 865 }); 866 867 var helpContent = $('#help-content'); 868 function toggleHelpContent(e) { 869 helpContent.toggle(); 870 871 e.stopPropagation(); 872 } 873 874 $('#help-toggle').on('click', toggleHelpContent); 875 876 877 var appsContent = $('#apps-content'); 878 function toggleAppsContent(e) { 879 appsContent.toggle(); 880 881 e.stopPropagation(); 882 } 883 884 $('#apps-toggle').on('click', toggleAppsContent); 885 886 887 888 $('html').on('click', function (e) { 889 if (helpContent.is(":visible")) { 890 helpContent.toggle(); 891 e.stopPropagation(); 892 } 893 if (appsContent.is(":visible")) { 894 appsContent.toggle(); 895 e.stopPropagation(); 896 } 897 }); 898 899 document.addEventListener('click', function (event) { 900 var isClickInsideMenu = document.querySelector('.navbar-collapse').contains(event.target); 901 var toggler = document.querySelector('.navbar-toggler'); 902 903 var isClickOnToggler = toggler ? toggler.contains(event.target) : false; 904 905 if (!isClickInsideMenu && !isClickOnToggler) { 906 $('.navbar-collapse').collapse('hide'); 907 } 908 }); 909 910 // Helper function to read language from IndexedDB 911 function getLanguageFromIndexedDB() { 912 return new Promise((resolve, reject) => { 913 var request = indexedDB.open('goblin-tools-db', 1); 914 915 request.onupgradeneeded = function(event) { 916 var db = event.target.result; 917 if (!db.objectStoreNames.contains('settings')) { 918 db.createObjectStore('settings'); 919 } 920 }; 921 922 request.onsuccess = function(event) { 923 var db = event.target.result; 924 var transaction = db.transaction('settings', 'readonly'); 925 var store = transaction.objectStore('settings'); 926 var getRequest = store.get('gt_lang'); 927 928 getRequest.onsuccess = function() { 929 resolve(getRequest.result); 930 }; 931 932 getRequest.onerror = function() { 933 resolve(null); 934 }; 935 }; 936 937 request.onerror = function(event) { 938 console.error('[Page] Failed to read from IndexedDB:', event.target.error); 939 resolve(null); 940 }; 941 }); 942 } 943 944 // Helper function to save language to IndexedDB for service worker 945 function saveLanguageToIndexedDB(languageCode) { 946 var request = indexedDB.open('goblin-tools-db', 1); 947 948 request.onupgradeneeded = function(event) { 949 var db = event.target.result; 950 if (!db.objectStoreNames.contains('settings')) { 951 db.createObjectStore('settings'); 952 } 953 }; 954 955 request.onsuccess = function(event) { 956 var db = event.target.result; 957 var transaction = db.transaction('settings', 'readwrite'); 958 var store = transaction.objectStore('settings'); 959 store.put(languageCode, 'gt_lang'); 960 console.log('[Page] Saved language to IndexedDB:', languageCode); 961 }; 962 963 request.onerror = function(event) { 964 console.error('[Page] Failed to save language to IndexedDB:', event.target.error); 965 }; 966 } 967 968 // Language selector change handler - made async to wait for IndexedDB 969 async function handleLanguageChange() { 970 var selectedLanguage = this.value; 971 972 // Save to localStorage SYNCHRONOUSLY (most reliable in WebViews) 973 try { 974 localStorage.setItem('gt_lang', selectedLanguage); 975 console.log('[Page] Saved language to localStorage:', selectedLanguage); 976 } catch (e) { 977 console.error('[Page] Failed to save language to localStorage:', e); 978 } 979
980 // Notify service worker to invalidate caches 981 if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { 982 try { 983 var messageChannel = new MessageChannel(); 984 navigator.serviceWorker.controller.postMessage({ 985 type: 'INVALIDATE_LANGUAGE_CACHE', 986 newLanguage: selectedLanguage 987 }, [messageChannel.port2]); 988 989 await new Promise(function(resolve) { 990 messageChannel.port1.onmessage = function() { resolve(); }; 991 setTimeout(resolve, 100); 992 }); 993 } catch (error) { 994 console.warn('[Language] Could not notify service worker:', error); 995 } 996 } 997 998 // Save to IndexedDB SYNCHRONOUSLY before redirecting 999 try { 1000 await new Promise(function(resolve, reject) { 1001 var request = indexedDB.open('goblin-tools-db', 1); 1002 1003 request.onerror = function() { 1004 console.warn('[Language] IndexedDB open failed, continuing anyway'); 1005 resolve(); 1006 }; 1007 1008 request.onsuccess = function(event) { 1009 var db = event.target.result; 1010 try { 1011 var transaction = db.transaction('settings', 'readwrite'); 1012 var store = transaction.objectStore('settings'); 1013 store.put(selectedLanguage, 'gt_lang'); 1014 1015 // CRITICAL: Wait for transaction to complete 1016 transaction.oncomplete = function() { 1017 console.log('[Language] IndexedDB updated successfully'); 1018 resolve(); 1019 }; 1020 1021 transaction.onerror = function() { 1022 console.warn('[Language] IndexedDB transaction failed, continuing anyway'); 1023 resolve(); 1024 }; 1025 1026 // Timeout fallback - don't wait forever 1027 setTimeout(function() { 1028 console.warn('[Language] IndexedDB commit timeout, continuing anyway'); 1029 resolve(); 1030 }, 500); 1031 } catch (e) { 1032 console.warn('[Language] IndexedDB error:', e); 1033 resolve(); 1034 } 1035 }; 1036 1037 request.onupgradeneeded = function(event) { 1038 var db = event.target.result; 1039 if (!db.objectStoreNames.contains('settings')) { 1040 db.createObjectStore('settings'); 1041 } 1042 }; 1043 }); 1044 } catch (error) { 1045 console.warn('[Language] IndexedDB failed:', error); 1046 } 1047 1048 // Call server endpoint to set cookie 1049 $.ajax({ 1050 url: '/api/setlanguage', 1051 method: 'POST', 1052 contentType: 'application/json', 1053 data: JSON.stringify({ LanguageCode: selectedLanguage }), 1054 success: function(response) { 1055 // Always append ?l=XX to URL for consistency 1056 window.location.href = window.location.pathname + '?l=' + selectedLanguage; 1057 }, 1058 error: function() { 1059 console.error('Error setting language - falling back to client-side cookie'); 1060 1061 // Fallback to client-side cookie setting if server call fails 1062 var expires = new Date(); 1063 expires.setFullYear(expires.getFullYear() + 1); 1064 1065 var cookieString = 'gt_lang=' + selectedLanguage + '; path=/; SameSite=Lax; expires=' + expires.toUTCString(); 1066 if (window.location.hostname.endsWith('goblin.tools')) { 1067 cookieString += '; domain=.goblin.tools'; 1068 } 1069 1070 if (window.location.protocol === 'https:') { 1071 cookieString += '; Secure'; 1072 } 1073 1074 document.cookie = cookieString; 1075 1076 // Reload with language parameter 1077 window.location.href = window.location.pathname + '?l=' + selectedLanguage; 1078 } 1079 }); 1080 } 1081 1082 // Initialize Choices.js on all language selectors 1083 document.querySelectorAll('.language-selector').forEach(function(selector) { 1084 // const options = Array.from(selector.options); 1085 // console.log(JSON.stringify(options[0])) 1086 1087 // const dataoptions = options.map(option => ({ 1088 // value: option.value, 1089 // label: option.dataset.nativeName, 1090 // id: option.index + 1, 1091 // customProperties: { 1092 // nativeName: option.dataset.nativeName, 1093 // englishName: option.dataset.englishName 1094 // } 1095 // })); 1096 1097 const dataoptions = [ 1098 1099 { 1100 value: "en", 1101 label: "Automatic", 1102 id: 0 + 1, 1103 disabled: false, 1104 customProperties: { 1105 nativeName: "Automatic", 1106 englishName: "English, but responds like your input", 1107 } 1108 }, 1109 1110 { 1111 value: "af", 1112 label: "Afrikaans", 1113 id: 1 + 1, 1114 disabled: false, 1115 customProperties: { 1116 nativeName: "Afrikaans", 1117 englishName: "Afrikaans", 1118 } 1119 }, 1120 1121 { 1122 value: "en-AU", 1123 label: "Australian", 1124 id: 2 + 1, 1125 disabled: false, 1126 customProperties: { 1127 nativeName: "Australian", 1128 englishName: "English", 1129 } 1130 }, 1131 1132 { 1133 value: "id", 1134 label: "Bahasa Indonesia", 1135 id: 3 + 1, 1136 disabled: false, 1137 customProperties: { 1138 nativeName: "Bahasa Indonesia", 1139 englishName: "Indonesian", 1140 } 1141 }, 1142 1143 { 1144 value: "ms", 1145 label: "Bahasa Melayu", 1146 id: 4 + 1, 1147 disabled: false, 1148 customProperties: { 1149 nativeName: "Bahasa Melayu", 1150 englishName: "Malay", 1151 } 1152 }, 1153 1154 { 1155 value: "bs", 1156 label: "Bosanski", 1157 id: 5 + 1, 1158 disabled: false, 1159 customProperties: { 1160 nativeName: "Bosanski", 1161 englishName: "Bosnian", 1162 } 1163 }, 1164 1165 { 1166 value: "en-GB", 1167 label: "British", 1168 id: 6 + 1, 1169 disabled: false, 1170 customProperties: { 1171 nativeName: "British", 1172 englishName: "English", 1173 } 1174 }, 1175 1176 { 1177 value: "cs", 1178 label: "Čeština", 1179 id: 7 + 1, 1180 disabled: false, 1181 customProperties: { 1182 nativeName: "Čeština", 1183
1183 englishName: "Czech", 1184 } 1185 }, 1186 1187 { 1188 value: "cy", 1189 label: "Cymraeg", 1190 id: 8 + 1, 1191 disabled: false, 1192 customProperties: { 1193 nativeName: "Cymraeg", 1194 englishName: "Welsh", 1195 } 1196 }, 1197 1198 { 1199 value: "da", 1200 label: "Dansk", 1201 id: 9 + 1, 1202 disabled: false, 1203 customProperties: { 1204 nativeName: "Dansk", 1205 englishName: "Danish", 1206 } 1207 }, 1208 1209 { 1210 value: "de", 1211 label: "Deutsch", 1212 id: 10 + 1, 1213 disabled: false, 1214 customProperties: { 1215 nativeName: "Deutsch", 1216 englishName: "German", 1217 } 1218 }, 1219 1220 { 1221 value: "et", 1222 label: "Eesti", 1223 id: 11 + 1, 1224 disabled: false, 1225 customProperties: { 1226 nativeName: "Eesti", 1227 englishName: "Estonian", 1228 } 1229 }, 1230 1231 { 1232 value: "es", 1233 label: "Español", 1234 id: 12 + 1, 1235 disabled: false, 1236 customProperties: { 1237 nativeName: "Español", 1238 englishName: "Spanish", 1239 } 1240 }, 1241 1242 { 1243 value: "eu", 1244 label: "Euskera", 1245 id: 13 + 1, 1246 disabled: false, 1247 customProperties: { 1248 nativeName: "Euskera", 1249 englishName: "Basque", 1250 } 1251 }, 1252 1253 { 1254 value: "fr", 1255 label: "Français", 1256 id: 14 + 1, 1257 disabled: false, 1258 customProperties: { 1259 nativeName: "Français", 1260 englishName: "French", 1261 } 1262 }, 1263 1264 { 1265 value: "ga", 1266 label: "Gaeilge", 1267 id: 15 + 1, 1268 disabled: false, 1269 customProperties: { 1270 nativeName: "Gaeilge", 1271 englishName: "Irish", 1272 } 1273 }, 1274 1275 { 1276 value: "gd", 1277 label: "Gàidhlig", 1278 id: 16 + 1, 1279 disabled: false, 1280 customProperties: { 1281 nativeName: "Gàidhlig", 1282 englishName: "Scottish Gaelic", 1283 } 1284 }, 1285 1286 { 1287 value: "ha", 1288 label: "Haus
1288a", 1289 id: 17 + 1, 1290 disabled: false, 1291 customProperties: { 1292 nativeName: "Hausa", 1293 englishName: "Hausa", 1294 } 1295 }, 1296 1297 { 1298 value: "hr", 1299 label: "Hrvatski", 1300 id: 18 + 1, 1301 disabled: false, 1302 customProperties: { 1303 nativeName: "Hrvatski", 1304 englishName: "Croatian", 1305 } 1306 }, 1307 1308 { 1309 value: "ig", 1310 label: "Igbo", 1311 id: 19 + 1, 1312 disabled: false, 1313 customProperties: { 1314 nativeName: "Igbo", 1315 englishName: "Igbo", 1316 } 1317 }, 1318 1319 { 1320 value: "is", 1321 label: "Íslenska", 1322 id: 20 + 1, 1323 disabled: false, 1324 customProperties: { 1325 nativeName: "Íslenska", 1326 englishName: "Icelandic", 1327 } 1328 }, 1329 1330 { 1331 value: "it", 1332 label: "Italiano", 1333 id: 21 + 1, 1334 disabled: false, 1335 customProperties: { 1336 nativeName: "Italiano", 1337 englishName: "Italian", 1338 } 1339 }, 1340 1341 { 1342 value: "sw", 1343 label: "Kiswahil
1343i", 1344 id: 22 + 1, 1345 disabled: false, 1346 customProperties: { 1347 nativeName: "Kiswahili", 1348 englishName: "Swahili", 1349 } 1350 }, 1351 1352 { 1353 value: "tlh", 1354 label: "Klingon", 1355 id: 23 + 1, 1356 disabled: false, 1357 customProperties: { 1358 nativeName: "Klingon", 1359 englishName: "Klingon", 1360 } 1361 }, 1362 1363 { 1364 value: "la", 1365 label: "Latin", 1366 id: 24 + 1, 1367 disabled: false, 1368 customProperties: { 1369 nativeName: "Latin", 1370 englishName: "Latin", 1371 } 1372 }, 1373 1374 { 1375 value: "lv", 1376 label: "Latviešu", 1377 id: 25 + 1, 1378 disabled: false, 1379 customProperties: { 1380 nativeName: "Latviešu", 1381 englishName: "Latvian", 1382 } 1383 }, 1384 1385 { 1386 value: "lt", 1387 label: "Lietuvių", 1388 id: 26 + 1, 1389 disabled: false, 1390 customProperties: { 1391 nativeName: "Lietuvių", 1392 englishName: "Lithuanian", 1393 } 1394 }, 1395 1396 { 1397 value: "hu", 1398 label: "Magyar", 1399 id: 27 + 1, 1400 disabled: false, 1401 customProperties: { 1402 nativeName: "Magyar", 1403 englishName: "Hungarian", 1404 } 1405 }, 1406 1407 { 1408 value: "nl", 1409 label: "Nederlands", 1410 id: 28 + 1, 1411 disabled: false, 1412 customProperties: { 1413 nativeName: "Nederlands", 1414 englishName: "Dutch", 1415 } 1416 }, 1417 1418 { 1419 value: "no", 1420 label: "Norsk", 1421 id: 29 + 1, 1422 disabled: false, 1423 customProperties: { 1424 nativeName: "Norsk", 1425 englishName: "Norweg
1425ian", 1426 } 1427 }, 1428 1429 { 1430 value: "uz", 1431 label: "O'zbek", 1432 id: 30 + 1, 1433 disabled: false, 1434 customProperties: { 1435 nativeName: "O'zbek", 1436 englishName: "Uzbek", 1437 } 1438 }, 1439 1440 { 1441 value: "pl", 1442 label: "Polski", 1443 id: 31 + 1, 1444 disabled: false, 1445 customProperties: { 1446 nativeName: "Polski", 1447 englishName: "Polish", 1448 } 1449 }, 1450 1451 { 1452 value: "pt", 1453 label: "Português", 1454 id: 32 + 1, 1455 disabled: false, 1456 customProperties: { 1457 nativeName: "Português", 1458 englishName: "Portuguese", 1459 } 1460 }, 1461 1462 { 1463 value: "qya", 1464 label: "Quenya", 1465 id: 33 + 1, 1466 disabled: false, 1467 customProperties: { 1468 nativeName: "Quenya", 1469 englishName: "Quenya", 1470 } 1471 }, 1472 1473 { 1474 value: "ro", 1475 label: "Română", 1476 id: 34 + 1, 1477 disabled: false, 1478 customProperties: { 1479 nativeName: "Română", 1480 englishName: "Romanian", 1481 } 1482 }, 1483 1484 { 1485 value: "sq", 1486 label: "Shqip", 1487 id: 35 + 1, 1488 disabled: false, 1489 customProperties: { 1490 nativeName: "Shqip", 1491 englishName: "Albanian", 1492 } 1493 }, 1494 1495 { 1496 value: "sjn", 1497 label: "Sindarin", 1498 id: 36 + 1, 1499 disabled: false, 1500 customProperties: { 1501 nativeName: "Sindarin", 1502 englishName: "Sindarin", 1503 } 1504 }, 1505 1506 { 1507 value: "sk", 1508 label: "Slovenčina", 1509 id: 37 + 1, 1510 disabled: false, 1511 customProperties: { 1512 nativeName: "Slovenčina", 1513 englishName: "Slovak", 1514 } 1515 }, 1516 1517 { 1518 value: "sl", 1519 label: "Slovenščina", 1520 id: 38 + 1, 1521 disabled: false, 1522 customProperties: { 1523 nativeName: "Slovenščina", 1524 englishName: "Slovene", 1525 } 1526 }, 1527 1528 { 1529 value: "fi", 1530 label: "Suomi", 1531 id: 39 + 1, 1532 disabled: false, 1533 customProperties: { 1534 nativeName: "Suomi", 1535 englishName: "Finnish", 1536 } 1537 }, 1538 1539 { 1540 value: "sv", 1541 label: "Svenska", 1542 id: 40 + 1, 1543 disabled: false, 1544 customProperties: { 1545 nativeName: "Svenska", 1546 englishName: "Swedish", 1547 } 1548 }, 1549 1550 { 1551 value: "tl", 1552 label: "Tagalog", 1553 id: 41 + 1, 1554
1554 disabled: false, 1555 customProperties: { 1556 nativeName: "Tagalog", 1557 englishName: "Tagalog", 1558 } 1559 }, 1560 1561 { 1562 value: "vi", 1563 label: "Tiếng Việt", 1564 id: 42 + 1, 1565 disabled: false, 1566 customProperties: { 1567 nativeName: "Tiếng Việt", 1568 englishName: "Vietnamese", 1569 } 1570 }, 1571 1572 { 1573 value: "tr", 1574 label: "Türkçe", 1575 id: 43 + 1, 1576 disabled: false, 1577 customProperties: { 1578 nativeName: "Türkçe", 1579 englishName: "Turkish", 1580 } 1581 }, 1582 1583 { 1584 value: "en-US", 1585 label: "US English", 1586 id: 44 + 1, 1587 disabled: false, 1588 customProperties: { 1589 nativeName: "US English", 1590 englishName: "English", 1591 } 1592 }, 1593 1594 { 1595 value: "yo", 1596 label: "Yorùbá", 1597 id: 45 + 1, 1598 disabled: false, 1599 customProperties: { 1600 nativeName: "Yorùbá", 1601 englishName: "Yoruba", 1602 } 1603 }, 1604 1605 { 1606 value: "el", 1607 label: "Ελληνικά", 1608 id: 46 + 1, 1609 disabled: false, 1610 customProperties: { 1611 nativeName: "Ελληνικά", 1612 englishName: "Greek", 1613 } 1614 }, 1615 1616 { 1617 value: "bg", 1618 label: "Български", 1619 id: 47 + 1, 1620 disabled: false, 1621 customProperties: { 1622 nativeName: "Български", 1623 englishName: "Bulgarian", 1624 } 1625 }, 1626 1627 { 1628 value: "ky", 1629 label: "Кыргызча", 1630 id: 48 + 1, 1631 disabled: false, 1632 customProperties: { 1633 nativeName: "Кыргызча", 1634 englishName: "Kyrgyz", 1635 } 1636 }, 1637 1638 { 1639 value: "kk", 1640 label: "Қазақ тілі", 1641 id: 49 + 1, 1642 disabled: false, 1643 customProperties: { 1644 nativeName: "Қазақ тілі", 1645 englishName: "Kazakh", 1646 } 1647 }, 1648 1649 { 1650 value: "mk", 1651 label: "Македонски", 1652 id: 50 + 1, 1653 disabled: false, 1654 customProperties: { 1655 nativeName: "Македонски", 1656 englishName: "Macedonian", 1657 } 1658 }, 1659 1660 { 1661 value: "ru", 1662 label: "Русский", 1663 id: 51 + 1, 1664 disabled: false, 1665 customProperties: { 1666 nativeName: "Русский", 1667 englishName: "Russian", 1668 } 1669 }, 1670 1671 { 1672 value: "sr", 1673 label: "Српски", 1674 id: 52 + 1, 1675 disabled: false, 1676
1676 customProperties: { 1677 nativeName: "Српски", 1678 englishName: "Serbian", 1679 } 1680 }, 1681 1682 { 1683 value: "tg", 1684 label: "Тоҷикӣ", 1685 id: 53 + 1, 1686 disabled: false, 1687 customProperties: { 1688 nativeName: "Тоҷикӣ", 1689 englishName: "Tajik", 1690 } 1691 }, 1692 1693 { 1694 value: "uk", 1695 label: "Українська", 1696 id: 54 + 1, 1697 disabled: false, 1698 customProperties: { 1699 nativeName: "Українська", 1700 englishName: "Ukrainian", 1701 } 1702 }, 1703 1704 { 1705 value: "he", 1706 label: "עברית", 1707 id: 55 + 1, 1708 disabled: false, 1709 customProperties: { 1710 nativeName: "עברית", 1711 englishName: "Hebrew", 1712 } 1713 }, 1714 1715 { 1716 value: "ur", 1717 label: "اردو", 1718 id: 56 + 1, 1719 disabled: false, 1720 customProperties: { 1721 nativeName: "اردو", 1722 englishName: "Urdu", 1723 } 1724 }, 1725 1726 { 1727 value: "ar", 1728 label: "العربية", 1729 id: 57 + 1, 1730 disabled: false, 1731 customProperties: { 1732 nativeName: "العربية", 1733 englishName: "Standard Arabic", 1734 } 1735 }, 1736 1737 { 1738 value: "fa", 1739 label: "فارسی", 1740 id: 58 + 1, 1741 disabled: false, 1742 customProperties: { 1743 nativeName: "فارسی", 1744 englishName: "Farsi", 1745 } 1746 }, 1747 1748 { 1749 value: "am", 1750 label: "አማርኛ", 1751 id: 59 + 1, 1752 disabled: false, 1753 customProperties: { 1754 nativeName: "አማርኛ", 1755 englishName: "Amharic", 1756 } 1757 }, 1758 1759 { 1760 value: "ne", 1761 label: "नेपाली", 1762 id: 60 + 1, 1763 disabled: false, 1764 customProperties: { 1765 nativeName: "नेपाली", 1766 englishName: "Nepali", 1767 } 1768 }, 1769 1770 { 1771 value: "mr", 1772 label: "मराठी", 1773 id: 61 + 1, 1774 disabled: false, 1775 customProperties: { 1776 nativeName: "मराठी", 1777 englishName: "Marathi", 1778 } 1779 }, 1780 1781 { 1782 value: "hi", 1783 label: "हिन्दी", 1784 id: 62 + 1, 1785 disabled: false, 1786 customProperties: { 1787 nativeName: "हिन्दी", 1788 englishName: "Hindi", 1789 } 1790 }, 1791 1792 { 1793 value: "bn", 1794 label: "বাংলা", 1795 id: 63 + 1, 1796 disabled: false, 1797 customProperties: { 1798
1798 nativeName: "বাংলা", 1799 englishName: "Bengali", 1800 } 1801 }, 1802 1803 { 1804 value: "pa", 1805 label: "ਪੰਜਾਬੀ", 1806 id: 64 + 1, 1807 disabled: false, 1808 customProperties: { 1809 nativeName: "ਪੰਜਾਬੀ", 1810 englishName: "Punjabi", 1811 } 1812 }, 1813 1814 { 1815 value: "gu", 1816 label: "ગુજરાતી", 1817 id: 65 + 1, 1818 disabled: false, 1819 customProperties: { 1820 nativeName: "ગુજરાતી", 1821 englishName: "Gujarati", 1822 } 1823 }, 1824 1825 { 1826 value: "ta", 1827 label: "தமிழ்", 1828 id: 66 + 1, 1829 disabled: false, 1830 customProperties: { 1831 nativeName: "தமிழ்", 1832 englishName: "Tamil", 1833 } 1834 }, 1835 1836 { 1837 value: "te", 1838 label: "తెలుగు", 1839 id: 67 + 1, 1840 disabled: false, 1841 customProperties: { 1842 nativeName: "తెలుగు", 1843 englishName: "Telugu", 1844 } 1845 }, 1846 1847 { 1848 value: "th", 1849 label: "ไทย", 1850 id: 68 + 1, 1851 disabled: false, 1852 customProperties: { 1853 nativeName: "ไทย", 1854 englishName: "Thai", 1855 } 1856 }, 1857 1858 { 1859 value: "ko", 1860 label: "한국어", 1861 id: 69 + 1, 1862 disabled: false, 1863 customProperties: { 1864 nativeName: "한국어", 1865 englishName: "Korean", 1866 } 1867 }, 1868 1869 { 1870 value: "zh", 1871 label: "中文", 1872 id: 70 + 1, 1873 disabled: false, 1874 customProperties: { 1875 nativeName: "中文", 1876 englishName: "Chinese", 1877 } 1878 }, 1879 1880 { 1881 value: "ja", 1882 label: "日本語", 1883 id: 71 + 1, 1884 disabled: false, 1885 customProperties: { 1886 nativeName: "日本語", 1887 englishName: "Japanese", 1888 } 1889 }, 1890 1891 { 1892 value: "zh-CN", 1893 label: "简体中文", 1894 id: 72 + 1, 1895 disabled: false, 1896 customProperties: { 1897 nativeName: "简体中文", 1898 englishName: "Chinese (Simplified)", 1899 } 1900 }, 1901 1902 { 1903 value: "zh-TW", 1904 label: "繁體中文", 1905 id: 73 + 1, 1906 disabled: false, 1907 customProperties: { 1908 nativeName: "繁體中文", 1909 englishName: "Chinese (Traditional)", 1910 } 1911 }, 1912 ] 1913 const selected = dataoptions.find(option => option.value === "en"); 1914 //console.log(JSON.stringify(selected)) 1915 const choicesInstance = new Choices(selector, { 1916 searchEnabled: true, 1917 itemSelectText: '', 1918 shouldSort: false, 1919 choices: dataoptions, 1920 items: selected, 1921 renderSelectedChoices: true, 1922 searchFields: ['label', 'value', 'customProperties.englishName','customProperties.nativeName'], 1923 callbackOnCreateTemplates: function (template) { 1924 return { 1925 item: ({classNames}, data) => { 1926 //console.log("Item: " + JSON.stringify(data)) 1927 //var mydata = dataoptions.find(option => option.value === data.value); 1928 return template(` 1929 1930 <div class="${classNames.item} ${data.highlighted ? classNames.highlightedState : classNames.itemSelectable}" data-item data-id="${data.id}" data-value="${data.value}" ${data.active ? 'aria-selected="true"' : ''} ${data.disabled ? 'aria-disab
1930led="true"' : ''}> 1931 ${data.customProperties.nativeName} 1932 </div> 1933 `); 1934 }, 1935 choice: ({classNames}, data) => { 1936 //console.log("Choice: " + JSON.stringify(data)) 1937 //var mydata = dataoptions.find(option => option.value === data.value); 1938 return template(` 1939 1940 <div class="${classNames.item} ${classNames.itemChoice} ${data.disabled ? classNames.itemDisabled : classNames.itemSelectable}" data-select-text="${this.config.itemSelectText}" data-choice data-id="${data.id}" data-value="${data.value}" ${data.disabled ? 'aria-disabled="true"' : ''} id="${data.elementId}"> 1941 <div class="language-native-bold">${data.customProperties.nativeName}</div> 1942 <div class="language-english-italic">${data.customProperties.englishName}</div> 1943 </div> 1944 `); 1945 } 1946 }; 1947 } 1948 }); 1949 1950 1951 // choicesInstance.setChoices(dataoptions, 'value', 'label', true); 1952 // choicesInstance.setChoiceByValue(selected.value); 1953 1954 selector.addEventListener('change', handleLanguageChange); 1955 }); 1956 1957 // Initialize IndexedDB with current language on page load 1958 (function() { 1959 var currentLang = 'en'; 1960 if (currentLang) { 1961 saveLanguageToIndexedDB(currentLang); 1962 } 1963 })(); 1964 1965 // Synchronize language across all storage locations 1966 (async function() { 1967 var detectedLang = null; 1968 1969 try { 1970 // Priority 1: Check URL query parameter ?l=XX 1971 var urlParams = new URLSearchParams(window.location.search); 1972 var langParam = urlParams.get('l'); 1973 if (langParam) { 1974 localStorage.setItem('gt_lang', langParam); 1975 detectedLang = langParam; 1976 console.log('[Lang Sync] Language set from URL parameter:', langParam); 1977 } 1978 1979 // Priority 2: Read from localStorage (single source of truth) 1980 if (!detectedLang) { 1981 detectedLang = localStorage.getItem('gt_lang'); 1982 if (detectedLang) { 1983 console.log('[Lang Sync] Language from localStorage:', detectedLang); 1984 } 1985 } 1986 1987 // Priority 3: Fallback to browser language 1988 if (!detectedLang && navigator.languages && navigator.languages.length > 0) { 1989 detectedLang = navigator.languages[0]; 1990 localStorage.setItem('gt_lang', detectedLang); 1991 console.log('[Lang Sync] Language from navigator.languages:', detectedLang); 1992 } 1993 1994 // Sync cookie and IndexedDB to match localStorage 1995 if (detectedLang) { 1996 // Sync cookie via API call 1997 $.ajax({ 1998 url: '/api/setlanguage', 1999 method: 'POST', 2000 contentType: 'application/json', 2001 data: JSON.stringify({ LanguageCode: detectedLang }), 2002 success: function() { 2003 console.log('[Lang Sync] Cookie synced to:', detectedLang); 2004 }, 2005 error: function() { 2006 console.warn('[Lang Sync] Failed to sync cookie via API, setting directly'); 2007 var cookieString = 'gt_lang=' + detectedLang + '; path=/; max-age=31536000; SameSite=Lax'; 2008 if (window.location.hostname.endsWith('goblin.tools')) { 2009 cookieString += '; domain=.goblin.tools'; 2010 } 2011 if (window.location.protocol === 'https:') { 2012 cookieString += '; Secure'; 2013 } 2014 document.cookie = cookieString; 2015 } 2016 }); 2017 2018 // Sync IndexedDB 2019 saveLanguageToIndexedDB(detectedLang); 2020 console.log('[Lang Sync] IndexedDB synced to:', detectedLang); 2021 } 2022 } catch (e) { 2023 console.error('[Lang Sync] Failed to sync language:', e); 2024 } 2025 })(); 2026 2027 // Global timer storage for bfcache cleanup 2028 var navigationTimers = { 2029 initialTimer: null, 2030 longerTimer: null, 2031 clear: function() { 2032 if (this.initialTimer) { 2033 clearTimeout(this.initialTimer); 2034 this.initialTimer = null; 2035 console.log('[Navigation] Cleared initialTimer'); 2036 } 2037 if (this.longerTimer) { 2038 clearTimeout(this.longerTimer); 2039 this.longerTimer = null; 2040 console.log('[Navigation] Cleared longerTimer'); 2041 } 2042 } 2043 }; 2044 2045 // Propagate language parameter across navigation 2046 document.addEventListener('DOMContentLoaded', function() { 2047 // Intercept all link clicks 2048 document.addEventListener('click', async function(e) { 2049 var target = e.target; 2050 2051 // Find the closest <a> tag (in case user clicked child element) 2052 while (target && target.tagName !== 'A') { 2053 target = target.parentElement; 2054 } 2055 2056 // Not a link, ignore 2057 if (!target || target.tagName !== 'A') return; 2058 2059 var href = target.getAttribute('href'); 2060 2061 // Skip if no href, or external links, or anchors, or javascript: 2062 if (!href || 2063 href.startsWith('#') || 2064 href.startsWith('javascript:') || 2065 href.startsWith('mailto:') || 2066 href.startsWith('tel:') || 2067 target.target === '_blank') { 2068 return; 2069 } 2070 2071 // Check if it's a same-domain link 2072 try { 2073 var linkUrl = new URL(href, window.location.href); 2074 2075 // Only modify same-domain links 2076 if (linkUrl.hostname === window.location.hostname) { 2077 // Prevent default navigation 2078 e.preventDefault(); 2079 2080 // Check if this is a mobile domain link 2081 var isMobileDomain = linkUrl.hostname === 'android.goblin.tools' || 2082 linkUrl.hostname === 'ios.goblin.tools'; 2083 2084 // Only add language parameter for mobile domains 2085 if (isMobileDomain) { 2086 // Read language from localStorage (single source of truth) 2087 var currentLang = localStorage.getItem('gt_lang'); 2088 2089 if (currentLang) { 2090 // Sync cookie via API 2091 $.ajax({ 2092 url: '/api/setlanguage', 2093 method: 'POST', 2094 contentType: 'application/json', 2095 data: JSON.stringify({ LanguageCode: currentLang }), 2096 async: false 2097 }); 2098 2099 // Sync IndexedDB 2100 await new Promise(function(resolve) { 2101 var request = indexedDB.open('goblin-tools-db', 1); 2102 request.onsuccess = function(event) { 2103 var db = event.target.result; 2104 var transaction = db.transaction('settings', 'readwrite'); 2105 var store = transaction.objectStore('settings'); 2106 store.put(currentLang, 'gt_lang'); 2107 transaction.oncomplete = function() { resolve(); }; 2108 setTimeout(resolve, 100); 2109 }; 2110 request.onerror = function() { resolve(); }; 2111 }); 2112 2113 // Add language parameter to URL 2114 var linkParams = new URLSearchParams(linkUrl.search); 2115 linkParams.set('l', currentLang); 2116 linkUrl.search = linkParams.toString(); 2117 } 2118 } 2119 2120 // If navigating to homepage on mobile subdomain, add loaded=1 to skip loader 2121 var isMobileSubdomain = window.location.hostname === 'ios.goblin.tools' || 2122 window.location.hostname === 'android.goblin.tools' || 2123 window.location.hostname === 'localhost'; 2124 2125 if (isMobileSubdomain && 2126 (linkUrl.pathname === '/' || linkUrl.pathname === '')) { 2127 var linkParams = new URLSearchParams(linkUrl.search); 2128 if (!linkParams.has('loaded')) { 2129 linkParams.set('loaded', '1'); 2130 linkUrl.search = linkParams.toString(); 2131 } 2132 } 2133 2134 // Store the target URL 2135 var targetUrl = linkUrl.toString(); 2136 2137 // Get overlay elements
2138 var overlay = document.getElementById('loadingOverlay'); 2139 var longerText = document.getElementById('loadingLongerText'); 2140 var cancelBtn = document.getElementById('loadingCancelBtn'); 2141 2142 // Flag to track if navigation should proceed 2143 var shouldNavigate = true; 2144 2145 // Clear any existing timers from previous navigation 2146 navigationTimers.clear(); 2147 2148 // Navigate (timers will continue until page unloads or they fire) 2149 window.location.href = targetUrl; 2150 2151 // Cancel button handler 2152 cancelBtn.onclick = function() { 2153 console.log('[Navigation] Cancel button clicked'); 2154 shouldNavigate = false; 2155 navigationTimers.clear(); 2156 overlay.classList.remove('show'); 2157 window.stop(); 2158 }; 2159 2160 // Start 500ms timer 2161 navigationTimers.initialTimer = setTimeout(function() { 2162 console.log('[Navigation] initialTimer fired, shouldNavigate:', shouldNavigate); 2163 if (!shouldNavigate) return; 2164 2165 // Show overlay with main loading text (hide longer text and cancel initially) 2166 longerText.style.display = 'none'; 2167 cancelBtn.style.display = 'none'; 2168 overlay.classList.add('show'); 2169 console.log('[Navigation] Overlay shown'); 2170 2171 // Start 2-second timer for longer text 2172 navigationTimers.longerTimer = setTimeout(function() { 2173 console.log('[Navigation] longerTimer fired, shouldNavigate:', shouldNavigate); 2174 if (!shouldNavigate) return; 2175 longerText.style.display = 'block'; 2176 cancelBtn.style.display = 'block'; 2177 console.log('[Navigation] "Taking longer" message shown'); 2178 }, 2000); 2179 }, 500); 2180 } 2181 } catch (err) { 2182 // If URL parsing fails, let the link work normally 2183 return; 2184 } 2185 }); 2186 }); 2187 2188 // Fix for bfcache (back/forward cache) - hide loading overlay when page is restored 2189 window.addEventListener('pageshow', function(event) { 2190 // If page was restored from bfcache (back/forward navigation) 2191 if (event.persisted) { 2192 console.log('[BFCache] Page restored from bfcache, cleaning up navigation state'); 2193 2194 // CRITICAL: Clear all pending navigation timers 2195 navigationTimers.clear(); 2196 2197 // Hide the loading overlay 2198 var overlay = document.getElementById('loadingOverlay'); 2199 if (overlay) { 2200 overlay.classList.remove('show'); 2201 console.log('[BFCache] Overlay hidden'); 2202 } 2203 2204 // Also hide the longer text and cancel button 2205 var longerText = document.getElementById('loadingLongerText'); 2206 var cancelBtn = document.getElementById('loadingCancelBtn'); 2207 if (longerText) { 2208 longerText.style.display = 'none'; 2209 console.log('[BFCache] Longer text hidden'); 2210 } 2211 if (cancelBtn) { 2212 cancelBtn.style.display = 'none'; 2213 console.log('[BFCache] Cancel button hidden'); 2214 } 2215 } 2216 }); 2217 2218 </script>
2218 2219
2219<script> 2220 // Service worker registration + version-update UX. 2221 // 2222 // The SW no longer calls skipWaiting() from its own install 2223 // handler â it stays in "waiting" state until this page decides 2224 // it's safe to switch over. "Safe" means: no input/textarea is 2225 // focused or holds non-trivial text. If safe, we activate the 2226 // new SW immediately and reload. Otherwise we show a small toast 2227 // anchored to the bottom of the screen with copy 2228 // "A new version is available. Tap here to reload." â tapping it 2229 // performs the same flow, after which the user only loses the 2230 // current input. This avoids mid-edit auto-reloads (which would 2231 // wipe a Formalizer paragraph the user is half-way through). 2232 (function () { 2233 // SW support is not assumed. iOS WKWebView (the in-app 2234 // browser the goblin.tools iOS app uses), private-mode 2235 // Safari, and some embedded webviews don't expose 2236 // `navigator.serviceWorker`. In those cases the SW 2237 // registration / update flow is skipped, but we still need 2238 // to paint the version marker (with the no-SW sentinel) and 2239 // wire up its click-to-force-refresh â otherwise the marker 2240 // sits at the literal `v?` from the HTML template forever. 2241 var swSupported = ('serviceWorker' in navigator); 2242 2243 // No update-notification machinery on purpose. The service worker 2244 // serves HTML network-first, so every navigation or refresh already 2245 // fetches the current version and its version-hashed assets. A "new 2246 // version available" toast would only ever matter to someone parked 2247 // on a single page indefinitely â and even they get the update on 2248 // their next action â so it was noise: it fired on every reload with 2249 // DevTools "Update on reload", and on tapping the version marker. 2250 // A newly-installed worker simply stays waiting and takes over on the 2251 // next tab close; nothing reloads the page unprompted. 2252 2253 // The PAGE version is the truth: it's stamped into the 2254 // marker server-side by Razor (from appsettings AppVersion) 2255 // and exposed as `window.APP_VERSION`. We only query the SW 2256 // for *its* SW_VERSION to detect a stale controller â i.e. 2257 // a case where the user has the new HTML but the old SW is 2258 // still in charge. When that happens we annotate the 2259 // marker so the mismatch is visible in screenshots; in the 2260 // healthy case the marker just shows the page version 2261 // unmodified. 2262 function normalizeVersionString(v) { 2263 return (v || '').replace(/-/g, '.').replace(/^v/, ''); 2264 } 2265 2266 function showMismatch(marker, swVer) { 2267 marker.textContent = 'v' + (window.APP_VERSION || '?') 2268 + ' (sw ' + normalizeVersionString(swVer) + ')'; 2269 } 2270 2271 function askWorkerForVersion(worker, marker) { 2272 try { 2273 var ch = new MessageChannel(); 2274 ch.port1.onmessage = function (ev) { 2275 if (!ev.data || ev.data.type !== 'VERSION') return; 2276 var swVer = normalizeVersionString(ev.data.version); 2277 var pageVer = normalizeVersionString(window.APP_VERSION); 2278 if (swVer && pageVer && swVer !== pageVer) { 2279 showMismatch(marker, ev.data.version); 2280 } 2281 // Match: leave the server-stamped marker alone. 2282 }; 2283 worker.postMessage({ type: 'GET_VERSION' }, [ch.port2]); 2284 } catch (e) { /* old SW with no GET_VERSION handler â leave marker as-is */ } 2285 } 2286 2287 function refreshVersionMarker() { 2288 var marker = document.getElementById('sw-version-marker'); 2289 if (!marker) return; 2290 // Server-rendered marker already shows the page version.
2291 // Without a SW (iOS WKWebView, private mode, etc.) the 2292 // page version IS the truth â leave the marker alone. 2293 if (!swSupported) return; 2294 // First preference: the SW currently controlling the 2295 // page. We only message it to check for a version 2296 // mismatch with the page. 2297 var ctrl = navigator.serviceWorker.controller; 2298 if (ctrl) { 2299 askWorkerForVersion(ctrl, marker); 2300 return; 2301 } 2302 // No controller (most commonly: this navigation was a 2303 // hard refresh). The registered SW can still respond 2304 // to messages, so we can still cross-check. 2305 if (navigator.serviceWorker.getRegistration) { 2306 navigator.serviceWorker.getRegistration().then(function (reg) { 2307 if (reg && reg.active) { 2308 askWorkerForVersion(reg.active, marker); 2309 } 2310 }).catch(function () { /* leave server-stamped marker as-is */ }); 2311 } 2312 } 2313 2314 // Clicking the version marker performs a "force refresh": 2315 // (1) ask the SW registration to re-check /sw.js so a newly 2316 // deployed worker gets picked up *now* rather than waiting 2317 // for the browser's 24h SW-script update timer; 2318 // (2) if a worker is sitting in `waiting`, activate it via 2319 // SKIP_WAITING so the reload happens under the new 2320 // controller (with the new STATIC_CACHE namespace); 2321 // (3) wipe every Cache Storage entry the SW owns â the SW's 2322 // next fetch handler runs are all cache misses and fall 2323 // through to the network; 2324 // (4) reload. The reload itself can't be made truly "hard" 2325 // from JS (the boolean arg to reload() was removed in 2326 // modern browsers); steps 1-3 are what give this click 2327 // real teeth â without them, clearing caches alone left 2328 // a stale SW + stale HTTP-cached HTML in charge after 2329 // the reload, defeating the whole point of the marker. 2330 // SW registration is left intact so the next page still has 2331 // offline support. Wired up regardless of SW support so 2332 // tapping the marker in a no-SW context still triggers a 2333 // plain reload. This is the ONLY path that activates a waiting 2334 // worker (via SKIP_WAITING) â and it runs its own clearCaches + 2335 // reload chain right here, so the reload is always the user's 2336 // explicit choice. 2337 function attachMarkerClickHandler() { 2338 var marker = document.getElementById('sw-version-marker'); 2339 if (!marker) return; 2340 var triggerForceRefresh = function () { 2341 marker.textContent = '...'; 2342 2343 var swStep = (swSupported && navigator.serviceWorker.getRegistration) 2344 ? navigator.serviceWorker.getRegistration().then(function (reg) { 2345 if (!reg) return; 2346 return reg.update().catch(function () { /* ignore */ }).then(function () { 2347 if (reg.waiting) { 2348 try { reg.waiting.postMessage({ type: 'SKIP_WAITING' }); } catch (e) {} 2349 } 2350 }); 2351 }).catch(function () { /* swallow â keep going */ }) 2352 : Promise.resolve(); 2353 2354 var clearCaches = (typeof caches !== 'undefined' && caches.keys) 2355 ? caches.keys().then(function (names) { 2356 return Promise.all(names.map(function (n) { return caches.delete(n); })); 2357 }).catch(function () { /* swallow â reload anyway */ }) 2358 : Promise.resolve(); 2359 2360 swStep.then(function () { return clearCaches; }) 2361 .then(function () { window.location.reload(); }); 2362 }; 2363 marker.addEventListener('click', triggerForceRefresh); 2364 marker.addEventListener('keydown', function (e) { 2365 if (e.key === 'Enter' || e.key === ' ') { 2366 e.preventDefault(); 2367 triggerForceRefresh(); 2368 } 2369 }); 2370 } 2371 2372 window.addEventListener('load', function () {
2373 // Marker + click handler run unconditionally so iOS 2374 // WKWebView (no SW) still shows the sentinel and is 2375 // clickable to force a plain reload. 2376 refreshVersionMarker(); 2377 attachMarkerClickHandler(); 2378 2379 if (!swSupported) return; 2380 2381 navigator.serviceWorker.register('/sw.js').then(function (registration) { 2382 console.log('ServiceWorker registration successful with scope: ', registration.scope); 2383 2384 // A newly-installed/waiting worker is left to activate on the 2385 // next tab close (standard lifecycle). We deliberately do NOT 2386 // surface it or auto-activate it â see the note by the SW 2387 // registration helpers above. Content freshness comes from the 2388 // network-first HTML strategy, not from swapping the worker. 2389 2390 // Refresh once registration finishes â if `active` was 2391 // null at initial paint it may now have a worker. 2392 refreshVersionMarker(); 2393 }, function (err) { 2394 // Registration failed â leave whatever refreshVersionMarker 2395 // settled on. Most commonly that's already the sentinel 2396 // (no controller, no registration, fallback hit). 2397 console.log('ServiceWorker registration failed: ', err); 2398 }); 2399 2400 // Keep the version marker in step when the controller changes 2401 // (e.g. after a tap-the-marker force refresh). No reload-on- 2402 // controllerchange handler: nothing activates a worker behind the 2403 // user's back anymore, and the marker's own force-refresh runs its 2404 // reload directly. 2405 navigator.serviceWorker.addEventListener('controllerchange', refreshVersionMarker); 2406 }); 2407 })(); 2408 </script>
2408 2409 2410 <!-- 100% privacy friendly analytics --> 2411
2411<script defer data-hostname="goblin.tools" src="https://stats.goblin.tools/latest.js"></script>
2411 2412 <noscript b-0bai7cs8n1><img b-0bai7cs8n1 src="https://stats.goblin.tools/noscript.gif?hostname=goblin.tools" alt="" referrerpolicy="no-referrer-when-downgrade" /></noscript> 2413 <!-- Loading overlay for navigation --> 2414 <div b-0bai7cs8n1 class="loading-overlay" id="loadingOverlay"> 2415 <div b-0bai7cs8n1 class="loading-overlay-content"> 2416 <div b-0bai7cs8n1 class="loading-spinner"></div> 2417 <div b-0bai7cs8n1 class="loading-text" data-translate="loading.loading">Loading...</div> 2418 <div b-0bai7cs8n1 class="loading-longer-text" id="loadingLongerText" data-translate="loading.taking_longer">This is taking longer than expected</div> 2419 <button b-0bai7cs8n1 class="loading-cancel-btn" id="loadingCancelBtn" data-translate="loading.cancel">Cancel</button> 2420 </div> 2421 </div> 2422 2423
2423<script> 2424 // Server-emitted bootstrap. Deliberately carries no user id: the 2425 // service worker caches HTML in DYNAMIC_CACHE and ignores 2426 // Cache-Control entirely, so anything rendered into the markup can 2427 // outlive a sign-out on a shared browser. pro-auth.js fetches 2428 // identity from /api/account/session instead. 2429 // 2430 // MUST be defined before pro-auth.js loads â that script bails at 2431 // its first line if window.GT_PRO is missing. 2432 window.GT_PRO = { 2433 enabled: true, 2434 signedIn: false, 2435 pro: false, 2436 clerkJsUrl: "https://clerk.goblin.tools/npm/@clerk/clerk-js@5/dist/clerk.browser.js", 2437 publishableKey: "pk_live_Y2xlcmsuZ29ibGluLnRvb2xzJA" 2438 }; 2439 2440 // Pro's JS strings, MERGED into window.i18n rather than assigned. 2441 // Being after RenderSectionAsync is load-bearing: ToDo.cshtml and 2442 // Taskmaster.cshtml both ASSIGN window.i18n inside their Scripts 2443 // section, so merging any earlier would be clobbered on exactly the 2444 // two pages that matter most. A page may declare a section only 2445 // once and there is exactly one RenderSectionAsync in this file, so 2446 // anywhere after it is safe. 2447 window.i18n = Object.assign({}, window.i18n || {}, { 2448 'pro.buttonUpgrade': "Pro", 2449 'pro.buttonAccount': "Account", 2450 'pro.tooltipUpgrade': "Get Goblin Tools Pro", 2451 'pro.tooltipAccount': "Your account", 2452 'pro.checkoutRedirect': "Taking you to checkoutâ¦", 2453 'pro.activating': "Payment received, activating your subscriptionâ¦", 2454 'pro.errorGeneric': "Something went wrong. Please try again.", 2455 'pro.errorCheckout': "Couldn\'t start checkout. Please try again.", 2456 'pro.authInvalid': "The email, username or password you entered is incorrect.", 2457 'pro.bannerSignedIn': "You\'re signed in. One more step to unlock Goblin Tools Pro.", 2458 'pro.subscribeCta': "Go to checkout", 2459 'pro.gateShare': "Sharing lists is part of Goblin Tools Pro. Want to unlock it?", 2460 'pro.gateTemplates': "Saving your own templates is part of Goblin Tools Pro. Want to unlock it?", 2461 'pro.gateHistory': "Keeping your history across the tools is part of Goblin Tools Pro. Want to unlock it?", 2462 'pro.gateGeneric': "This is part of Goblin Tools Pro. Want to unlock it?", 2463 'pro.gateFormalizerTones': "Saving your own tones is part of Goblin Tools Pro. Want to unlock it?", 2464 'pro.gateFormalizerVoice': "Rewriting text in your own writing style is part of Goblin Tools Pro. Want to unlock it?", 2465 'pro.gateFormalizerCompare': "Comparing several tones at once is part of Goblin Tools Pro. Want to unlock it?", 2466 'pro.gateCta': "Get Pro", 2467 'pro.gateClose': "Close", 2468 'pro.signOut': "Sign out", 2469 'pro.settings': "Settings", 2470 'pro.welcomeTitle': "You\'re all set", 2471 'pro.welcomeBody': "Pro is active on this account. Your Magic ToDo will start syncing, and history is now being kept on every tool.", 2472 'pro.resume': "Renew", 2473 'pro.graceWarningDays': "Your Goblin Tools Pro subscription has ended. Renew within {0} days to keep your access.", 2474 'pro.graceWarningOneDay': "Your Goblin Tools Pro subscription has ended. Renew today to keep your access.", 2475 'pro.graceWarningToday': "Your Goblin Tools Pro subscription has ended. Renew now to keep your access.", 2476 'history.title': "History", 2477 'history.loading': "Loadingâ¦", 2478 'history.empty': "Nothing here yet. Anything you make on this page will show up here.", 2479 'history.error': "Couldn\'t load your history.", 2480 'history.clear': "Clear", 2481 'history.clearConfirm': "Clear your history for this page? This cannot be undone.", 2482 'history.truncated': "This one was too big to store in full.", 2483 'history.untitled': "(no text)" 2484 }); 2485 </script>
2485 2486
2486<script src="https://goblin.tools/js/pro-auth.js?v=8JObYbpS9xD-ujZjMQ2w7c-oVgsZE1MHe-47Ap7BPdo"></script>
2486 2487
2487<script src="https://goblin.tools/js/pro-history.js?v=wJu-jY2FI0Lvi2lk_YjBYws3LEwN58_gr4_CB-GKfC0"></script>
2487 2488 2489</body> 2490</html>
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.