PageSourceSearch

https://hellpanderrr.github.io/wiktionary_pron/scripts/utils.js

js hellpanderrr.github.io collected 2026-10-03 09:17:33 UTC 27,610 bytes, 866 lines download raw bytes

1async function asyncMapStrict(arr, fn) {
2  const result = [];
3  // console.time("Elapsed time :");
4  for (let idx = 0; idx < arr.length; idx += 1) {
5    const cur = arr[idx];
6    await new Promise((resolve) => setTimeout(resolve, 0.0001));
7
8    result.push(await fn(cur, idx, arr));
9  }
10  // console.timeEnd("Elapsed time :")
11  return result;
12}
13
14function sanitize(text) {
15  return text
16      .replace(/[^\p{L}\p{M}'’‿-]/gu, "")
17      .replaceAll("’", "'")
18      .normalize("NFKC");
19}
20
21/**
22 * Memoizes the given function in local storage.
23 * @param {Function} fn - The function to memoize.
24 * @param {Object} options - Memoization options.
25 * @param {number} options.ttl - Time to live for cached results in milliseconds.
26 * @param {boolean} options.backgroundRefresh - Whether to refresh the cache in the background.
27 * @throws {Error} Throws an error if the function is anonymous.
28 * @returns {Function} Returns the memoized function.
29 */
30/**
31 * Memoizes both sync and async functions in localStorage with TTL support
32 * @param {Function} fn - The function to memoize
33 * @param {Object} options - Memoization options
34 * @param {number} options.ttl - Time to live in milliseconds
35 * @param {boolean} options.backgroundRefresh - Whether to refresh cache in background
36 */
37function memoizeLocalStorage(
38  fn,
39  options = { ttl: 100, backgroundRefresh: false },
40) {
41  if (!fn.name) {
42    console.warn("Warning: memoizeLocalStorage called with anonymous function");
43    throw new Error("memoizeLocalStorage only accepts non-anonymous functions");
44  }
45
46  let cache = JSON.parse(localStorage.getItem(fn.name) || "{}");
47  const debug = options.debug || false;
48
49  function log(...args) {
50    if (debug) console.log(`[Memoize:${fn.name}]`, ...args);
51  }
52
53  async function executeAndCache(args, argsKey) {
54    log("Executing function with args:", args);
55    try {
56      const result = fn(...args);
57      const isPromise = result instanceof Promise;
58
59      const finalResult = isPromise ? await result : result;
60      const cacheEntry = {
61        expiration: Date.now() + options.ttl,
62        result: finalResult,
63        isPromise,
64      };
65
66      cache[fn.name] = {
67        ...cache[fn.name],
68        [argsKey]: cacheEntry,
69      };
70
71      try {
72        localStorage.setItem(fn.name, JSON.stringify(cache));
73        log("Cached result:", finalResult);
74      } catch (e) {
75        console.warn(`Cache storage failed for ${fn.name}:`, e);
76        // Clear old entries if storage fails
77        cache = { [fn.name]: { [argsKey]: cacheEntry } };
78        localStorage.setItem(fn.name, JSON.stringify(cache));
79      }
80
81      return finalResult;
82    } catch (error) {
83      console.error(`Execution failed for ${fn.name}:`, error);
84      throw error;
85    }
86  }
87
88  return function memoized(...args) {
89    const argsKey = JSON.stringify(args);
90    const now = Date.now();
91    const currentCache = cache[fn.name]?.[argsKey];
92
93    if (currentCache && currentCache.expiration > now) {
94      log("Cache hit");
95
96      // Background refresh near expiration
97      if (
98        options.backgroundRefresh &&
99        currentCache.expiration - now < options.ttl * 0.2
100      ) {
101        log("Starting background refresh");
102        executeAndCache(args, argsKey).catch((error) =>
103          console.error(`Background refresh failed for ${fn.name}:`, error),
104        );
105      }
106
107      // Return cached result, wrapping in Promise if original was async
108      return currentCache.isPromise
109        ? Promise.resolve(currentCache.result)
110        : currentCache.result;
111    }
112
113    log("Cache miss");
114    return executeAndCache(args, argsKey);
115  };
116}
117
118// Utility functions for cache management
119memoizeLocalStorage.clearCache = function (fnName) {
120  if (fnName) {
121    localStorage.removeItem(fnName);
122  } else {
123    Object.keys(localStorage)
124      .filter((key) => localStorage.getItem(key).includes('"expiration":'))
125      .forEach((key) => localStorage.removeItem(key));
126  }
127};
128
129memoizeLocalStorage.getCacheStats = function (fnName) {
130  const cache = JSON.parse(localStorage.getItem(fnName) || "{}");
131  const now = Date.now();
132
133  return {
134    totalEntries: Object.keys(cache[fnName] || {}).length,
135    validEntries: Object.values(cache[fnName] || {}).filter(
136      (entry) => entry.expiration > now,
137    ).length,
138    oldestEntry: Math.min(
139      ...Object.values(cache[fnName] || {}).map((entry) => entry.expiration),
140    ),
141    newestEntry: Math.max(
142      ...Object.values(cache[fnName] || {}).map((entry) => entry.expiration),
143    ),
144  };
145};
146
147async function wait(ms = 1) {
148  await new Promise((resolve) => setTimeout(resolve, ms));
149}
150
151function clearStorage() {
152  // const cache = JSON.parse(localStorage.getItem("get_ipa_no_cache") || "{}");
153  // cache["get_ipa_no_cache"] = "";
154  // localStorage.setItem("get_ipa_no_cache", JSON.stringify(cache));
155  localStorage.clear();
156  localforage.clear();
157}
158
159function updateLoadingText(filePath, fileExtension, rawText) {
160  const loadingBar = document.getElementById("loading_text");
161  rawText = rawText || "";
162
163  if (!filePath && !fileExtension && !rawText) {
164    loadingBar.innerHTML = "";
165    loadingBar.style.display = "none";
166  } else if (filePath && fileExtension) {
167    loadingBar.innerHTML = `Loading ${filePath}.${fileExtension}`;
168    loadingBar.style.display = "block";
169  } else if (rawText) {
170    loadingBar.innerHTML = rawText;
171    loadingBar.style.display = "block";
172  }
173}
174
175/**
176 * Helper function to encapsulate the repeated lexicon lookup logic.
177 * @param {string} lexiconName - The name of the lexicon (e.g., "German").
178 * @param {string} text - The text to look up.
179 * @returns {string|null} The dictionary record or null if not found.
180 */
181function lookupInLexicon(lexiconName, text) {
182  if (!globalThis.lexicon || !globalThis.lexicon[lexiconName]) {
183    return null;
184  }
185  const lexicon = globalThis.lexicon[lexiconName];
186  const cleanedForLookup = text.replace(/[^\p{Letter}\p{Mark}-]+/gu, "");
187
188  let dictRecord = lexicon.get(cleanedForLookup);
189  if (!dictRecord) {
190    dictRecord = lexicon.get(cleanedForLookup.toLowerCase());
191  }
192
193  console.log(`Lexicon lookup for "${text}" in ${lexiconName}:`, dictRecord);
194  return dictRecord;
195}
196
197/**
198 * A map of language-specific handlers to replace the main switch statement.
199 * Each handler receives a context object and returns the calculated IPA string.
200 */
201const ipaHandlers = {
202  // --- Group 1: Unique/Complex Handlers ---
203
204  Latin: ({ cleanText, langStyle, langForm }) => {
205    const isPhonetic = langForm === "Phonetic";
206    const options = {
207      Ecclesiastical: { ecclesiastical: true, vulgar: false },
208      Classical: { ecclesiastical: false, vulgar: false },
209      Vulgar: { ecclesiastical: false, vulgar: true },
210    };
211    const styleOptions = options[langStyle];
212    if (!styleOptions) return null;
213
214    return window.la_ipa.convert_words(
215      cleanText,
216      isPhonetic,
217      styleOptions.ecclesiastical,
218      styleOptions.vulgar,
219    );
220  },
221
222  Portuguese: ({ cleanText, langStyle, langForm }) => {
223    const region = langStyle === "Brazil" ? "rio" : "pt";
224    const result = window.pt_ipa.IPA(cleanText, region);
225    return result?.[0]?.[langForm.toLowerCase()];
226  },
227
228  Spanish: ({ cleanText, langStyle, langForm }) => {
229    const dialect =
230      langStyle === "Castilian" ? "distincion-yeismo" : "seseo-yeismo";
231    const isPhonetic = langForm === "Phonetic";
232    const result = window.es_ipa.IPA(cleanText, dialect, isPhonetic);
233    return result?.text;
234  },
235
236  Ukrainian: ({ cleanText, langForm }) => {
237    if (langForm !== "Phonetic") return null;
238
239    // --- Stage 1: Check for a definitive version in the dictionary ---
240    if (globalThis.lexicon?.["Ukrainian"] && cleanText.trim().length > 0) {
241      const dictRecord =
242        globalThis.lexicon["Ukrainian"].get(
243          cleanText.replace(/[^\p{Letter}\p{Mark}-]+/gu, ""),
244        ) ||
245        globalThis.lexicon["Ukrainian"].get(
246          cleanText.replace(/[^\p{Letter}\p{Mark}-]+/gu, "").toLowerCase(),
247        );
248      if (dictRecord) {
249        console.log("Ukrainian: Found in dictionary:", dictRecord);
250
251        // 1. Split by comma in case there are multiple forms (e.g., "замо́к,за́мок").
252        // 2. Trim whitespace from each resulting word.
253        const stressedForms = dictRecord.split(",").map((word) => word.trim());
254
255        // Generate an IPA for each stressed form provided by the dictionary.
256        const ipas = stressedForms
257          .map((form) => window.uk_ipa.pronunciation(form, true))
258          .filter((ipa) => ipa) // Filter out any unsuccessful IPA generations
259          .map((ipa) => `/${ipa}/`) // Format each successful IPA
260          .join(","); // Join into the final string, e.g., "/ipa1/,/ipa2/"
261
262        return ipas || null;
263      }
264    }
265
266    // --- Stage 2: If not in dictionary, analyze and process ---
267    const VOWEL_REGEX = /[аеиіоуєюяї]/i;
268    const STRESS_MARK = "\u0301";
269    const applyStress = (vowel) => vowel + STRESS_MARK;
270
271    const syllables = cleanText.match(new RegExp(VOWEL_REGEX.source, "gi"));
272    const syllableCount = syllables ? syllables.length : 0;
273
274    // Case A: 0 or 1 syllables. Stress it and return a single IPA.
275    if (syllableCount <= 1) {
276      const stressedText =
277        syllableCount === 1
278          ? cleanText.replace(/[аеиіоуєюяїАЕИІОУЄЮЯЇ]/, applyStress)
279          : cleanText;
280      return window.uk_ipa.pronunciation(stressedText, true);
281    }
282
283    // Case B (Concise): Multi-syllable. Generate all variants using a functional chain.
284    const possibleIPAs = [...cleanText]
285      .map((char, index) => (VOWEL_REGEX.test(char) ? index : -1)) // Get indices of all vowels
286      .filter((index) => index !== -1) // Filter out non-vowel markers
287      .map((index) => {
288        // For each vowel index, create a stressed word and get its IPA
289        const stressedVowel = applyStress(cleanText[index]);
290        const stressedWord =
291          cleanText.slice(0, index) +
292          stressedVowel +
293          cleanText.slice(index + 1);
294        return window.uk_ipa.pronunciation(stressedWord, true);
295      })
296      .filter((ipa) => ipa) // Filter out any unsuccessful IPA generations
297      .map((ipa) => `/${ipa}/`) // Format each successful IPA
298      .join(","); // Join into the final string
299
300    return possibleIPAs || null; // Return the generated string or null if empty
301  },
302  Russian: ({ cleanText, langForm }) => {
303    if (langForm !== "Phonetic") return null;
304
305    // --- Stage 1: Check for definitive versions in the dictionary (Unchanged) ---
306    if (globalThis.lexicon?.["Russian"] && cleanText.trim().length > 0) {
307      const cleanedKey = cleanText.replace(/[^\p{Letter}\p{Mark}-]+/gu, "");
308
309      const dictRecord =
310        globalThis.lexicon["Russian"].get(cleanedKey) ||
311        globalThis.lexicon["Russian"].get(cleanedKey.toLowerCase());
312
313      function prioritizeStressedIpa(ipaString) {
314        return (
315          ipaString &&
316          Object.values(
317            // Use a Set to efficiently handle identical duplicates from the start
318            Array.from(new Set(ipaString.split(",")))
319              .map((ipa) => ipa.trim().replace(/^\/|\/$/g, "")) // Clean up each IPA string
320              .filter(Boolean) // Remove any empty strings
321              .reduce((best, ipa) => {
322                // Reduce the array to an object of the best versions
323                const base = ipa.replace(/ˈ/g, ""); // The key for grouping is the IPA without stress
324                const existing = best[base];
325                // The new `ipa` is better if no version exists yet, or if the new one is stressed and the old one isn't.
326                if (
327                  !existing ||
328                  (ipa.includes("ˈ") && !existing.includes("ˈ"))
329                ) {
330                  best[base] = ipa;
331                }
332                return best;
333              }, {}),
334          )
335            .map((ipa) => `/${ipa}/`)
336            .join(",")
337        ); // Format the final values back into a string
338      }
339
340      if (dictRecord) {
341        console.log("Russian: Found in dictionary:", dictRecord);
342        const stressedForms = dictRecord.split(",").map((word) => word.trim());
343        const ipas = stressedForms
344          .map((form) => window.ru_ipa.ipa_string(form))
345          .filter((ipa) => ipa)
346          .map((ipa) => `/${ipa}/`)
347          .join(",");
348        return prioritizeStressedIpa(ipas) || null;
349      }
350    }
351
352    // --- Stage 2: Fallback logic for words not in the dictionary  ---
353    const VOWEL_REGEX = /[аэиуеюяёоы]/i; // Russian vowels
354    const STRESS_MARK = "\u0301";
355    const applyStress = (vowel) => vowel + STRESS_MARK;
356
357    const syllables = cleanText.match(new RegExp(VOWEL_REGEX.source, "gi"));
358    const syllableCount = syllables ? syllables.length : 0;
359
360    // Case A: 0 or 1 syllables. Stress it and return a single IPA.
361    if (syllableCount <= 1) {
362      const stressedText =
363        syllableCount === 1
364          ? cleanText.replace(/[аэиуеюяёоАЭИУЕЮЯЁОЫ]/, applyStress) // Russian vowel set
365          : cleanText;
366      const singleIpa = window.ru_ipa.ipa_string(stressedText); // Russian IPA function
367      return singleIpa ? `/${singleIpa}/` : null;
368    }
369
370    // Case B: Multi-syllable. Generate all variants using a functional chain.
371    const possibleIPAs = [...cleanText]
372      .map((char, index) => (VOWEL_REGEX.test(char) ? index : -1))
373      .filter((index) => index !== -1)
374      .map((index) => {
375        const stressedVowel = applyStress(cleanText[index]);
376        const stressedWord =
377          cleanText.slice(0, index) +
378          stressedVowel +
379          cleanText.slice(index + 1);
380        return window.ru_ipa.ipa_string(stressedWord); // Russian IPA function
381      })
382      .filter((ipa) => ipa)
383      .map((ipa) => `/${ipa}/`)
384      .join(",");
385
386    return possibleIPAs || null;
387  },
388
389  Greek: ({ cleanText, langStyle }) => {
390    const styleMap = {
391      "5th BCE Attic": "cla",
392      "1st CE Egyptian": "koi1",
393      "4th CE Koine": "koi2",
394      "10th CE Byzantine": "byz1",
395      "15th CE Constantinopolitan": "byz2",
396    };
397    const styleCode = styleMap[langStyle];
398    if (!styleCode) return null;
399    const result = window.grc_ipa.IPA(cleanText, styleCode);
400    return result?.[styleCode]?.IPA;
401  },
402
403  Armenian: ({ cleanText, langStyle, langForm }) => {
404    const system = langStyle === "Western" ? "west" : "east";
405    const methodMap = { Phonemic: "phonemic_IPA", Phonetic: "phonetic_IPA" };
406    const method = methodMap[langForm];
407    return method ? window.hy_ipa[method](cleanText, system) : null;
408  },
409
410  Italian: ({ cleanText, langForm }) => {
411    if (langForm === "Phonemic") {
412      const result = window.it_ipa.to_phonemic(cleanText, "TEST");
413      return result?.phonemic;
414    }
415    return null;
416  },
417
418  // --- Group 2: Direct Generation for Phonemic form (No Lexicon) ---
419  ...Object.fromEntries(
420    ["Belorussian", "Bulgarian", "Polish", "Mongolian"].map((lang) => [
421      lang,
422      ({ cleanText, langForm }) => {
423        if (langForm !== "Phonemic") return null;
424        const generators = {
425          Belorussian: () => window.be_ipa.toIPA(cleanText),
426          Bulgarian: () => window.bg_ipa.toIPA(cleanText),
427          Polish: () => window.pl_ipa.convert_to_IPA(cleanText),
428          Mongolian: () => window.mn_ipa.to_IPA(cleanText),
429        };
430        return generators[lang]();
431      },
432    ]),
433  ),
434
435  // --- Irish G2P: 17-pass pipeline, no Wiktionary Lua pron module exists ---
436  Irish: ({ cleanText, langStyle }) => {
437    const dialectMap = {
438      Connacht: "connacht",
439      Munster: "munster",
440      Ulster: "ulster",
441    };
442    const dialect = dialectMap[langStyle] || "connacht";
443    return window.ga_ipa.transcribe(cleanText, dialect);
444  },
445
446  // --- Group 3: Lexicon Lookup with Generation Fallback ---
447  ...Object.fromEntries(
448    ["German", "French", "Czech", "Lithuanian", "Icelandic"].map((lang) => [
449      lang,
450      ({ cleanText, langForm, langStyle }) => {
451        // langStyle needed for French post-processing
452        let ipa = null;
453
454        // Step 1: Attempt lexicon lookup for Phonemic form
455        if (langForm === "Phonemic") {
456          const dictRecord = lookupInLexicon(lang, cleanText);
457          if (dictRecord) {
458            ipa = dictRecord;
459          }
460        }
461
462        // Step 2: If no record found, use a generator
463        if (ipa === null) {
464          const generators = {
465            German: {
466              Phonemic: () =>
466 window.de_ipa.phonemic(cleanText),
467              Phonetic: () => window.de_ipa.phonetic(cleanText),
468            },
469            French: {
470              Phonemic: () => window.fr_ipa.show(cleanText)?.[0],
471            },
472            Czech: {
473              Phonemic: () => window.cs_ipa.toIPA(cleanText),
474            },
475            Lithuanian: {
476              Phonemic: () => window.lt_ipa.toIPA(cleanText, true),
477            },
478            Icelandic: {
479              Phonemic: () => window.is_ipa.toIPA(cleanText, "", ""),
480            },
481          };
482          const langGenerator = generators[lang]?.[langForm];
483          if (langGenerator) {
484            ipa = langGenerator();
485          }
486        }
487
488        // Step 3: Apply language-specific post-processing
489        if (
490          lang === "French" &&
491          langStyle === "Parisian (experimental)" &&
492          ipa
493        ) {
494          ipa = ipa
495            .replace(/ɔ̃̃̃̃̃̃|ɔ̃/g, "õ")
496            .replace(/ɑ̃/g, "ɔ̃")
497            .replace(/œ̃|ɛ̃/g, "ɑ̃");
498        }
499
500        return ipa;
501      },
502    ]),
503  ),
504};
505
506/**
507 * Retrieves the IPA transcription for a given text without using a cache.
508 */
509function get_ipa_no_cache(text, args) {
510  const cleanText = sanitize(text);
511  console.log("doing actual IPA", text, cleanText, args);
512
513  const [lang, langStyle, langForm] = args.split(";");
514
515  let ipa = "";
516  try {
517    const handler = ipaHandlers[lang];
518    if (handler) {
519      ipa = handler({ cleanText, lang, langStyle, langForm }) || "";
520      console.log(`Handler for "${lang}" returned:`, ipa);
521    } else {
522      console.log(`No handler found for language: "${lang}"`);
523    }
524  } catch (err) {
525    ipa = ""; // Ensure ipa is reset on error
526    console.error("Error during IPA conversion:", err);
527  }
528
529  if (!ipa) {
530    return { value: text, status: "error" };
531  }
532
533  // The French post-processing has been moved into its specific handler.
534  // The main function is now cleaner.
535  console.log("final ipa ", ipa);
536  return { value: ipa, status: "success" };
537}
538function createElementFromHTML(htmlString) {
539  var div = document.createElement("div");
540  div.innerHTML = htmlString.trim();
541
542  // Change this to div.childNodes to support multiple top-level nodes.
543  return div.firstChild;
544}
545
546async function loadFileFromZipOrPath(zipPathOrBlob, filename) {
547  return new Promise((resolve, reject) => {
548    loadJs("./scripts/lib/jszip.min.js", async () => {
549      await loadJs("./scripts/lib/jszip-utils.min.js", async () => {
550        let data;
551        if (!(typeof zipPathOrBlob === "string")) {
552          console.log("blob", zipPathOrBlob);
553          data = zipPathOrBlob;
554        } else {
555          console.log("path", zipPathOrBlob);
556          const response = await fetchWithCache(zipPathOrBlob);
557          data = await response.blob();
558        }
559        JSZip.loadAsync(data)
560          .then(async (zip) => {
561            const fileData = await zip.file(filename).async("string");
562            resolve(fileData);
563          })
564          .catch((error) => {
565            console.error(error);
566            reject(error); // or handle the error as needed
567          });
568      });
569    });
570  });
571}
572
573async function sendParallelRequests(urls) {
574  const promises = urls.map((url) => fetch(url));
575
576  try {
577    const results = await Promise.any(promises);
578    return results;
579  } catch (error) {
580    return null; // Handle errors if no promises resolve successfully
581  }
582}
583
584async function fetchWithCacheMultiple(urls) {
585  const url = urls[0].split("/").slice(-2).join("/");
586
587  console.log("    reading cache", url);
588  const cachedResponse = await localforage.getItem(url);
589  if (cachedResponse) {
590    console.log("    reading from cache", url);
591    if (cachedResponse instanceof Blob) {
592      const response = new Response(cachedResponse);
593      response.headers.set("X-From-Cache", "true");
594      console.log("Returned cached blob ", url);
595      return response;
596    }
597    const response = new Response(JSON.parse(cachedResponse));
598    response.headers.set("X-From-Cache", "true");
599    console.log("    Returned cached string ", url);
600    return response;
601  }
602  console.log("    caching ", url);
603
604  // const response = await fetch(url);
605  const response = await sendParallelRequests(urls);
606
607  const contentType = response.headers.get("content-type");
608  let responseContent;
609  let responseWithHeaders;
610
611  if (contentType == "application/zip") {
612    responseContent = await response.blob();
613    try {
614      await localforage.setItem(url, responseContent);
615    } catch (err) {
616      console.log(err);
617    }
618  } else {
619    responseContent = await response.text();
620    try {
621      await localforage.setItem(url, JSON.stringify(responseContent));
622    } catch (err) {
623      console.log(err);
624    }
625  }
626  responseWithHeaders = new Response(responseContent, response);
627  return responseWithHeaders;
628}
629
630async function fetchWithCache(
631  url,
632  onProgress = (progress) => console.log(`Progress: ${progress.toFixed(2)}%`),
633) {
634  console.log("    reading cache", url);
635  let cachedResponse = await localforage.getItem(url);
636  if (cachedResponse) {
637    console.log("    reading from cache", url);
638    if (cachedResponse instanceof Blob) {
639      const response = new Response(cachedResponse);
640      response.headers.set("X-From-Cache", "true");
641      console.log("    Returned cached blob ", url);
642      return response;
643    }
644    const response = new Response(JSON.parse(cachedResponse));
645    response.headers.set("X-From-Cache", "true");
646    console.log("    Returned cached string ", url);
647    return response;
648  }
649  console.log("    caching ", url);
650  const response = await fetch(url);
651
652  const contentLength = response.headers.get("content-length");
653  const contentEncoding = response.headers.get("content-encoding");
654  const isCompressed =
655    contentEncoding === "gzip" ||
656    contentEncoding === "br" ||
657    contentEncoding === "deflate";
658
659  // If the content is compressed, we'll estimate the uncompressed size
660  // Average compression ratio for text/json is roughly 4:1, for binary files about 1.5:1
661  const contentType = response.headers.get("content-type");
662  const compressionRatio =
663    contentType?.includes("text") || contentType?.includes("json") ? 4 : 1.5;
664  const total =
665    isCompressed && contentLength
666      ? parseInt(contentLength, 10) * compressionRatio
667      : parseInt(contentLength, 10);
668
669  let loaded = 0;
670
671  const reader = response.body.getReader();
672  const stream = new ReadableStream({
673    start(controller) {
674      function read() {
675        reader
676          .read()
677          .then(({ done, value }) => {
678            if (done) {
679              controller.close();
680              return;
681            }
682            loaded += value.length;
683            if (onProgress && total) {
684              onProgress((loaded / total) * 100);
685            }
686            controller.enqueue(value);
687            read();
688          })
689          .catch((error) => {
690            console.error("    Stream reading error:", error);
691            controller.error(error);
692          });
693      }
694
695      read();
696    },
697  });
698
699  const newResponse = new Response(stream, {
700    headers: response.headers,
701  });
702
703  let responseContent;
704
705  if (contentType && contentType.includes("zip")) {
706    responseContent = await newResponse.blob();
707    try {
708      await localforage.setItem(url, responseContent);
709    } catch (err) {
710      console.log(err);
711    }
712  } else {
713    responseContent = await newResponse.text();
714    try {
715      await localforage.setItem(url, JSON.stringify(responseContent));
716    } catch (err) {
717      console.log(err);
718    }
719  }
720
721  return new Response(responseContent, {
722    headers: newResponse.headers,
723  });
724}
725
726const loadedScripts = {};
727
728async function loadJs(url, code) {
729  console.log(loadedScripts);
730  if (loadedScripts[url]) {
731    return new Promise((resolve) => {
732      setTimeout(() => {
733        code();
734        resolve();
735      }, 0);
736    });
737  }
738  loadedScripts[url] = true;
739
740  return new Promise((resolve, reject) => {
741    const script = document.createElement("script");
742
743    script.onload = () => {
744      code();
745      resolve();
746    };
747
748    script.onerror = reject;
749    script.src = url;
750
751    document.head.appendChild(script);
752  });
753}
754
755/**
756 * Disables all form elements on the page
757 */
758function disableAll(include_elements = []) {
759  // Select all the forms on the page
760  const forms = Array.from(document.querySelectorAll("form"));
761
762  // Iterate through each form and disable all its elements
763  forms.forEach((form) => {
764    Array.from(form.elements)
765      .concat(include_elements)
766      .forEach((element) => {
767        element.disabled = true;
768      });
769  });
770}
771
772function enableAll(include_elements = []) {
773  // Get all the form elements on the page
774  const forms = Array.from(document.querySelectorAll("form"));
775  forms.forEach((form) => {
776    // Enable all elements in the form
777    Array.from(form.elements)
778      .concat(include_elements)
779      .forEach((element) => {
780        element.disabled = false;
781      });
782  });
783}
784
785async function translateWithFallback(text, sourceLang = "auto", targetLang) {
786  if (!text.match(/\p{L}/u)) {
787    return {
788      text: text, // Return original text
789      source: "no-translation-needed",
790    };
791  }
792  // First attempt: GTX API
793  try {
794    await wait(1); // Rate limiting
795    const gtxUrl = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sourceLang}&tl=${targetLang}&dt=t&q=${encodeURIComponent(
796      text,
797    )}`;
798    const gtxResponse = await fetch(gtxUrl);
799
800    if (gtxResponse.ok) {
801      const gtxData = await gtxResponse.json();
802      return {
803        text: gtxData[0][0][0],
804        source: "google-gtx",
805      };
806    }
807  } catch (error) {
808    console.warn("GTX API failed:", error);
809  }
810
811  // Second attempt: Clients5 API
812  try {
813    await wait(1); // Rate limiting
814    const clients5Url = `https://clients5.google.com/translate_a/single?dj=1&dt=t&dt=sp&dt=ld&dt=bd&client=dict-chrome-ex&sl=${sourceLang}&tl=${targetLang}&q=${encodeURIComponent(
815      text,
816    )}`;
817    const clients5Response = await fetch(clients5Url);
818
819    if (clients5Response.ok) {
820      const clients5Data = await clients5Response.json();
821      return {
822        text: clients5Data.sentences[0].trans,
823        source: "google-clients5",
824      };
825    }
826  } catch (error) {
827    console.warn("Clients5 API failed:", error);
828  }
829
830  throw new Error("All translation attempts failed");
831}
832
833const translateWithFallbackWrapper = function (...args) {
834  return translateWithFallback(...args)
835    .then((result) => result)
836    .catch((error) => {
837      console.error("Translation failed:", error);
838      throw error;
839    });
840};
841
842// Now memoize the wrapper function
843const translateWithFallbackCached = memoizeLocalStorage(
844  translateWithFallbackWrapper,
845  {
846    ttl: 24 * 60 * 60 * 1000, // 24 hours
847    backgroundRefresh: true,
848  },
849);
850export {
851  asyncMapStrict,
852  sanitize,
853  clearStorage,
854  wait,
855  get_ipa_no_cache,
856  memoizeLocalStorage,
857  loadJs,
858  loadFileFromZipOrPath,
859  createElementFromHTML,
860  fetchWithCache,
861  fetchWithCacheMultiple,
862  disableAll,
863  enableAll,
864  updateLoadingText,
865  translateWithFallbackCached,
866};

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.