(function () { "use strict"; const PREFIX = "jess:mealie:"; const PROGRESS_PREFIX = `${PREFIX}cook-progress:`; const NOTES_PREFIX = `${PREFIX}recipe-notes:`; const LAST_RECIPE_KEY = `${PREFIX}last-recipe`; const ACTIVE_RECIPE_KEY = `${PREFIX}active-recipe`; const TIMER_KEY = `${PREFIX}cook-timer`; const RESTORE_MARK = "data-jess-cook-restored"; const TOOLBAR_ID = "jess-mealie-cook-tools"; const TIMER_ID = "jess-mealie-timer-toast"; const RECIPE_PATH = /\/(?:g\/[^/]+\/r|recipes?|recipe)\//i; const DURATION_TEXT = /(\d{1,3})(?:\s*(?:-|to|–|—)\s*(\d{1,3}))?\s*(seconds?|secs?|minutes?|mins?|hours?|hrs?)\b/gi; function normalize(value) { return (value || "").replace(/\s+/g, " ").trim().slice(0, 160); } function storageGet(key, fallback) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (_error) { return fallback; } } function storageSet(key, value) { localStorage.setItem(key, JSON.stringify(value)); window.dispatchEvent(new StorageEvent("storage", { key, newValue: JSON.stringify(value) })); } function routeText() { return `${window.location.pathname}${window.location.hash}${window.location.search}`; } function checkboxCount() { return allControls().length; } function onRecipePage() { if (RECIPE_PATH.test(routeText())) return true; const text = normalize(document.body ? document.body.textContent : ""); const hasRecipeText = text.includes("Ingredients") && text.includes("Instructions"); const hasCookModeText = text.includes("Cook Mode") || text.includes("Exit Cook Mode"); return (hasRecipeText || hasCookModeText) && checkboxCount() > 0; } function recipeSlug() { const path = `${window.location.pathname}${window.location.hash}`.replace(/\/+$/, ""); const slugMatch = path.match(/\/r\/([^/?#]+)/i) || path.match(/\/recipes?\/([^/?#]+)/i) || path.match(/\/recipe\/([^/?#]+)/i); if (slugMatch) return decodeURIComponent(slugMatch[1]); const heading = document.querySelector("h1, .v-toolbar-title, [class*='title']"); return normalize(heading ? heading.textContent : document.title || path) || path; } function recipeKey(prefix) { return `${prefix}${recipeSlug()}`; } function recipeTitle() { const candidates = [ document.querySelector("h1"), document.querySelector(".v-toolbar-title"), document.querySelector("[class*='title']"), ].filter(Boolean); for (const item of candidates) { const text = normalize(item.textContent); if (text && !["Mealie", "Recipes"].includes(text)) return text; } const title = normalize(document.title.replace(/[-|].*$/, "")); return title || recipeSlug(); } function recipeInfo(extra) { return { title: recipeTitle(), slug: recipeSlug(), path: `${window.location.pathname}${window.location.hash}`, url: window.location.href, updatedAt: new Date().toISOString(), ...extra, }; } function updateRecipeStatus(extra) { if (!onRecipePage()) return; const info = recipeInfo(extra); storageSet(LAST_RECIPE_KEY, info); storageSet(ACTIVE_RECIPE_KEY, { ...info, active: true }); } function allControls() { const selector = [ "main input[type='checkbox']", ".v-main input[type='checkbox']", "main [role='checkbox']", ".v-main [role='checkbox']", "input[type='checkbox']", ].join(", "); return Array.from(document.querySelectorAll(selector)).filter((control) => { if (control.closest(`#${TOOLBAR_ID}`) || control.closest(`#${TIMER_ID}`)) return false; if (control instanceof HTMLInputElement && control.disabled) return false; if (control.getAttribute("aria-disabled") === "true") return false; return true; }); } function labelText(control) { if (control.id) { const explicit = document.querySelector(`label[for="${CSS.escape(control.id)}"]`); if (explicit) return normalize(explicit.textContent); } const direct = normalize(control.getAttribute("aria-label") || control.getAttribute("title") || ""); if (direct) return direct; const label = control.closest("label"); if (label) return normalize(label.textContent); const nearby = control.closest(".v-list-item, .v-selection-control, .v-checkbox, li, tr, .v-card, .v-card-text"); if (nearby) { const text = normalize(nearby.textContent); if (text) return text; } let parent = control.parentElement; for (let depth = 0; parent && depth < 5; depth += 1) { const text = normalize(parent.textContent); if (text) return text; parent = parent.parentElement; } if (control instanceof HTMLInputElement) return normalize(control.name || control.value || ""); return ""; } function candidates() { if (!onRecipePage()) return []; return allControls(); } function indexedKeys(controls) { const seen = new Map(); return controls.map((control, index) => { const label = labelText(control) || `checkbox-${index}`; const count = seen.get(label) || 0; seen.set(label, count + 1); return `${index}:${count}:${label}`; }); } function isChecked(control) { if (control instanceof HTMLInputElement) return !!control.checked; return control.getAttribute("aria-checked") === "true"; } function setChecked(control, checked) { if (isChecked(control) !== checked) { control.click(); } if (control instanceof HTMLInputElement) { window.setTimeout(() => { if (control.checked !== checked) { control.checked = checked; control.dispatchEvent(new Event("input", { bubbles: true })); control.dispatchEvent(new Event("change", { bubbles: true })); } control.setAttribute(RESTORE_MARK, "true"); }, 0); return; } window.setTimeout(() => { if (isChecked(control) !== checked) { control.setAttribute("aria-checked", checked ? "true" : "false"); } control.setAttribute(RESTORE_MARK, "true"); }, 0); } function readProgress() { return storageGet(recipeKey(PROGRESS_PREFIX), {}); } function writeProgress() { const controls = candidates(); if (!controls.length) return; const keys = indexedKeys(controls); const checked = {}; controls.forEach((control, index) => { checked[keys[index]] = isChecked(control); }); storageSet(recipeKey(PROGRESS_PREFIX), { updatedAt: new Date().toISOString(), checked, }); updateRecipeStatus({ active: true }); } function restoreProgress() { const controls = candidates(); if (!controls.length) return; const state = readProgress(); if (!state.checked) return; const keys = indexedKeys(controls); controls.forEach((control, index) => { const saved = state.checked[keys[index]]; if (typeof saved !== "boolean") return; if (control.getAttribute(RESTORE_MARK) === "true" && isChecked(control) === saved) return; setChecked(control, saved); }); } function clearRecipeProgress() { localStorage.removeItem(recipeKey(PROGRESS_PREFIX)); candidates().forEach((control) => { setChecked(control, false); control.removeAttribute(RESTORE_MARK); }); updateRecipeStatus({ active: false, finishedAt: new Date().toISOString() }); } function noteKey() { return recipeKey(NOTES_PREFIX); } function getNotes() { return storageGet(noteKey(), []); } function saveLocalNote(text) { const notes = getNotes(); notes.push({ text, createdAt: new Date().toISOString() }); storageSet(noteKey(), notes); } function authHeaders() { const headers = { "Content-Type": "application/json" }; const sources = [localStorage, sessionStorage]; for (const source of sources) { for (let index = 0; index < source.length; index += 1) { const key = source.key(index); const value = source.getItem(key) || ""; if (!/token|auth|jwt/i.test(key + value)) continue; const tokenMatch = value.match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/); if (tokenMatch) { headers.Authorization = `Bearer ${tokenMatch[0]}`; return headers; } } } return headers; } async function addNote() { const text = window.prompt("Recipe note"); if (!normalize(text)) return; let savedToMealie = false; try { const recipe = await fetch(`/api/recipes/${encodeURIComponent(recipeSlug())}`, { credentials: "include", headers: authHeaders(), }).then((response) => (response.ok ? response.json() : null)); if (recipe && recipe.id) { const response = await fetch("/api/comments", { method: "POST", credentials: "include", headers: authHeaders(), body: JSON.stringify({ recipeId: recipe.id, text }), }); savedToMealie = response.ok; } } catch (_error) { savedToMealie = false; } if (!savedToMealie) saveLocalNote(text); flash(savedToMealie ? "Note added to Mealie" : "Note saved on this device"); renderToolbar(); } function durationSeconds(low, high, unit) { const amount = Math.max(Number(low), high ? Number(high) : Number(low)); const lowerUnit = unit.toLowerCase(); const seconds = /hour|hr/.test(lowerUnit) ? amount * 3600 : /sec/.test(lowerUnit) ? amount : amount * 60; if (seconds < 30 || seconds > 4 * 3600) return null; return seconds; } function timerRoots() { const roots = Array.from(document.querySelectorAll("main, .v-main")); return roots.length ? roots : [document.body].filter(Boolean); } function skipTimerTextNode(node) { const parent = node.parentElement; if (!parent) return true; DURATION_TEXT.lastIndex = 0; if (!DURATION_TEXT.test(node.nodeValue || "")) return true; DURATION_TEXT.lastIndex = 0; return !!parent.closest([ `#${TOOLBAR_ID}`, `#${TIMER_ID}`, ".jess-mealie-inline-timer", "[data-jess-inline-timer]", "a", "button", "input", "textarea", "select", "script", "style", "nav", "header", "aside", ".v-navigation-drawer", ".v-app-bar", ".v-toolbar", ].join(", ")); } function linkifyInstructionTimers() { if (!onRecipePage()) return; const nodes = []; timerRoots().forEach((root) => { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { return skipTimerTextNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }, }); let node; while ((node = walker.nextNode())) nodes.push(node); }); nodes.forEach((node) => { const text = node.nodeValue || ""; const fragment = document.createDocumentFragment(); let lastIndex = 0; let changed = false; DURATION_TEXT.lastIndex = 0; let match; while ((match = DURATION_TEXT.exec(text))) { const seconds = durationSeconds(match[1], match[2], match[3]); if (!seconds) continue; fragment.append(document.createTextNode(text.slice(lastIndex, match.index))); const button = document.createElement("button"); button.type = "button"; button.className = "jess-mealie-inline-timer"; button.setAttribute("data-jess-inline-timer", String(seconds)); button.textContent = match[0]; button.title = `Start ${match[0]} timer`; fragment.append(button); lastIndex = match.index + match[0].length; changed = true; } if (!changed) return; fragment.append(document.createTextNode(text.slice(lastIndex))); node.parentNode.replaceChild(fragment, node); }); } function startTimer(seconds, label) { const timer = { label, seconds, startedAt: Date.now(), endsAt: Date.now() + seconds * 1000, recipe: recipeTitle(), }; storageSet(TIMER_KEY, timer); ensureTimerToast(); updateTimerToast(); } function currentTimer() { const timer = storageGet(TIMER_KEY, null); if (!timer || !timer.endsAt) return null; return timer; } function stopTimer() { localStorage.removeItem(TIMER_KEY); const toast = document.getElementById(TIMER_ID); if (toast) toast.remove(); } function remainingSeconds(timer) { return Math.max(0, Math.ceil((timer.endsAt - Date.now()) / 1000)); } function formatSeconds(seconds) { const mins = Math.floor(seconds / 60); const secs = seconds % 60; if (mins >= 60) { const hours = Math.floor(mins / 60); const rem = mins % 60; return `${hours}:${String(rem).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; } return `${mins}:${String(secs).padStart(2, "0")}`; } function beep() { try { const context = new (window.AudioContext || window.webkitAudioContext)(); for (let i = 0; i < 3; i += 1) { const oscillator = context.createOscillator(); const gain = context.createGain(); oscillator.frequency.value = 880; gain.gain.value = 0.08; oscillator.connect(gain); gain.connect(context.destination); oscillator.start(context.currentTime + i * 0.35); oscillator.stop(context.currentTime + i * 0.35 + 0.22); } } catch (_error) { // Browser audio can be blocked until the page has user interaction. } } function ensureTimerToast() { if (document.getElementById(TIMER_ID)) return; const toast = document.createElement("div"); toast.id = TIMER_ID; toast.style.position = "fixed"; toast.style.left = "16px"; toast.style.bottom = "16px"; toast.style.zIndex = "2147483647"; toast.style.borderRadius = "8px"; toast.style.padding = "12px"; toast.style.background = "rgba(23, 31, 42, 0.94)"; toast.style.color = "#fff"; toast.style.font = "600 14px system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"; toast.style.boxShadow = "0 6px 22px rgba(0, 0, 0, 0.24)"; toast.style.minWidth = "190px"; document.body.appendChild(toast); } function updateTimerToast() { const timer = currentTimer(); if (!timer) return; ensureTimerToast(); const toast = document.getElementById(TIMER_ID); const remaining = remainingSeconds(timer); if (remaining <= 0) { if (!timer.done) { beep(); storageSet(TIMER_KEY, { ...timer, done: true }); } toast.innerHTML = `